Explorar el Código

Fix safe security findings: hashlib, log injection, broad excepts

- Add usedforsecurity=False to MD5 (AMS fingerprint) and SHA1 (git blob
  hash) calls to silence Bandit B303 / CodeQL weak-crypto findings
- Convert ~996 f-string logging calls to parameterized %s-style across
  55 files to prevent log injection (Bandit G201 / CodeQL log-injection)
- Narrow ~199 broad except Exception blocks to specific types:
  OperationalError for DB migrations, OSError for network/file cleanup,
  (OSError, ftplib.error_reply) for FTP, and targeted tuples for
  ZIP/XML/JSON parsing — 36 intentionally left broad (mixed async,
  re-raise patterns)
maziggy hace 3 meses
padre
commit
53bd4fadb3
Se han modificado 60 ficheros con 1720 adiciones y 1272 borrados
  1. 3 0
      .gitignore
  2. 29 31
      backend/app/api/routes/archives.py
  3. 8 8
      backend/app/api/routes/auth.py
  4. 41 37
      backend/app/api/routes/camera.py
  5. 2 2
      backend/app/api/routes/cloud.py
  6. 6 6
      backend/app/api/routes/external_links.py
  7. 4 4
      backend/app/api/routes/github_backup.py
  8. 4 4
      backend/app/api/routes/kprofiles.py
  9. 41 39
      backend/app/api/routes/library.py
  10. 1 1
      backend/app/api/routes/maintenance.py
  11. 4 4
      backend/app/api/routes/notifications.py
  12. 5 5
      backend/app/api/routes/pending_uploads.py
  13. 17 17
      backend/app/api/routes/print_queue.py
  14. 16 16
      backend/app/api/routes/printers.py
  15. 6 6
      backend/app/api/routes/projects.py
  16. 10 10
      backend/app/api/routes/settings.py
  17. 12 12
      backend/app/api/routes/smart_plugs.py
  18. 13 11
      backend/app/api/routes/spoolman.py
  19. 5 5
      backend/app/api/routes/support.py
  20. 7 7
      backend/app/api/routes/updates.py
  21. 3 3
      backend/app/api/routes/webhook.py
  22. 2 2
      backend/app/api/routes/websocket.py
  23. 3 3
      backend/app/core/auth.py
  24. 2 2
      backend/app/core/config.py
  25. 129 126
      backend/app/core/database.py
  26. 147 137
      backend/app/main.py
  27. 15 14
      backend/app/services/archive.py
  28. 6 6
      backend/app/services/bambu_cloud.py
  29. 59 59
      backend/app/services/bambu_ftp.py
  30. 130 124
      backend/app/services/bambu_mqtt.py
  31. 22 22
      backend/app/services/camera.py
  32. 43 43
      backend/app/services/discovery.py
  33. 47 47
      backend/app/services/external_camera.py
  34. 17 17
      backend/app/services/firmware_check.py
  35. 5 5
      backend/app/services/firmware_update.py
  36. 9 9
      backend/app/services/github_backup.py
  37. 14 13
      backend/app/services/homeassistant.py
  38. 16 14
      backend/app/services/layer_timelapse.py
  39. 9 9
      backend/app/services/mqtt_relay.py
  40. 17 17
      backend/app/services/mqtt_smart_plug.py
  41. 5 5
      backend/app/services/network_utils.py
  42. 28 28
      backend/app/services/notification_service.py
  43. 15 15
      backend/app/services/plate_detection.py
  44. 37 37
      backend/app/services/print_scheduler.py
  45. 5 5
      backend/app/services/printer_manager.py
  46. 31 27
      backend/app/services/smart_plug_manager.py
  47. 24 24
      backend/app/services/spoolman.py
  48. 26 26
      backend/app/services/spoolman_tracking.py
  49. 7 7
      backend/app/services/stl_thumbnail.py
  50. 9 9
      backend/app/services/tasmota.py
  51. 3 3
      backend/app/services/timelapse_processor.py
  52. 9 9
      backend/app/services/virtual_printer/certificate.py
  53. 33 33
      backend/app/services/virtual_printer/ftp_server.py
  54. 35 33
      backend/app/services/virtual_printer/manager.py
  55. 51 51
      backend/app/services/virtual_printer/mqtt_server.py
  56. 36 34
      backend/app/services/virtual_printer/ssdp_server.py
  57. 28 26
      backend/app/services/virtual_printer/tcp_proxy.py
  58. 4 3
      backend/app/utils/threemf_tools.py
  59. 4 0
      requirements-dev.txt
  60. 401 0
      test_security.sh

+ 3 - 0
.gitignore

@@ -61,3 +61,6 @@ data/
 
 
 # JWT secret file (should be in data dir, but protect project root too)
 # JWT secret file (should be in data dir, but protect project root too)
 .jwt_secret
 .jwt_secret
+
+# Security scan output
+*.sarif

+ 29 - 31
backend/app/api/routes/archives.py

@@ -1,4 +1,5 @@
 import io
 import io
+import json
 import logging
 import logging
 import zipfile
 import zipfile
 from pathlib import Path
 from pathlib import Path
@@ -180,7 +181,7 @@ async def search_archives(
         result = await db.execute(fts_query, {"search_term": search_term, "limit": limit + 100, "offset": 0})
         result = await db.execute(fts_query, {"search_term": search_term, "limit": limit + 100, "offset": 0})
         matched_ids = [row[0] for row in result.fetchall()]
         matched_ids = [row[0] for row in result.fetchall()]
     except Exception as e:
     except Exception as e:
-        logger.warning(f"FTS search failed, falling back to LIKE search: {e}")
+        logger.warning("FTS search failed, falling back to LIKE search: %s", e)
         # Fallback to LIKE search if FTS fails
         # Fallback to LIKE search if FTS fails
         like_pattern = f"%{q}%"
         like_pattern = f"%{q}%"
         query = (
         query = (
@@ -265,7 +266,7 @@ async def rebuild_search_index(
 
 
         return {"message": f"Search index rebuilt with {count} entries"}
         return {"message": f"Search index rebuilt with {count} entries"}
     except Exception as e:
     except Exception as e:
-        logger.error(f"Failed to rebuild search index: {e}")
+        logger.error("Failed to rebuild search index: %s", e)
         raise HTTPException(status_code=500, detail=f"Failed to rebuild index: {str(e)}")
         raise HTTPException(status_code=500, detail=f"Failed to rebuild index: {str(e)}")
 
 
 
 
@@ -941,7 +942,7 @@ async def rescan_all_archives(
 
 
             updated += 1
             updated += 1
         except Exception as e:
         except Exception as e:
-            logger.exception(f"Failed to rescan archive {archive.id}: {e}")
+            logger.exception("Failed to rescan archive %s: %s", archive.id, e)
             errors.append({"id": archive.id, "error": "Failed to parse 3MF file"})
             errors.append({"id": archive.id, "error": "Failed to parse 3MF file"})
 
 
     await db.commit()
     await db.commit()
@@ -992,7 +993,7 @@ async def backfill_content_hashes(
             archive.content_hash = ArchiveService.compute_file_hash(file_path)
             archive.content_hash = ArchiveService.compute_file_hash(file_path)
             updated += 1
             updated += 1
         except Exception as e:
         except Exception as e:
-            logger.exception(f"Failed to compute hash for archive {archive.id}: {e}")
+            logger.exception("Failed to compute hash for archive %s: %s", archive.id, e)
             errors.append({"id": archive.id, "error": "Failed to compute hash"})
             errors.append({"id": archive.id, "error": "Failed to compute hash"})
 
 
     await db.commit()
     await db.commit()
@@ -1262,7 +1263,7 @@ async def scan_timelapse(
         # Accept match within 4 hours (more lenient for timezone issues)
         # Accept match within 4 hours (more lenient for timezone issues)
         if best_match and best_diff < timedelta(hours=4):
         if best_match and best_diff < timedelta(hours=4):
             matching_file = best_match
             matching_file = best_match
-            logger.info(f"Matched timelapse by timestamp: {best_match.get('name')} (diff: {best_diff})")
+            logger.info("Matched timelapse by timestamp: %s (diff: %s)", best_match.get("name"), best_diff)
 
 
     # Strategy 3: Use file modification time from FTP listing
     # Strategy 3: Use file modification time from FTP listing
     # This handles cases where printer's filename timestamp is wrong but file mtime is correct
     # This handles cases where printer's filename timestamp is wrong but file mtime is correct
@@ -1290,7 +1291,7 @@ async def scan_timelapse(
 
 
         if best_match and best_diff < timedelta(hours=2):
         if best_match and best_diff < timedelta(hours=2):
             matching_file = best_match
             matching_file = best_match
-            logger.info(f"Matched timelapse by file mtime: {best_match.get('name')} (diff: {best_diff})")
+            logger.info("Matched timelapse by file mtime: %s (diff: %s)", best_match.get("name"), best_diff)
 
 
     # Strategy 4: If only one timelapse exists and archive was recently completed, use it
     # Strategy 4: If only one timelapse exists and archive was recently completed, use it
     # This handles cases where printer clock is wrong or timezone issues exist
     # This handles cases where printer clock is wrong or timezone issues exist
@@ -1303,7 +1304,7 @@ async def scan_timelapse(
             # If archive was completed within the last hour, assume the single timelapse is for it
             # If archive was completed within the last hour, assume the single timelapse is for it
             if time_since_completion < timedelta(hours=1):
             if time_since_completion < timedelta(hours=1):
                 matching_file = mp4_files[0]
                 matching_file = mp4_files[0]
-                logger.info(f"Using single timelapse file as fallback: {mp4_files[0].get('name')}")
+                logger.info("Using single timelapse file as fallback: %s", mp4_files[0].get("name"))
 
 
     # Note: We intentionally don't use a "most recent file" fallback because
     # Note: We intentionally don't use a "most recent file" fallback because
     # we can't verify if timelapse was actually enabled for this print.
     # we can't verify if timelapse was actually enabled for this print.
@@ -1505,7 +1506,7 @@ async def get_timelapse_info(
         info = await processor.get_info()
         info = await processor.get_info()
         return TimelapseInfoResponse(**info)
         return TimelapseInfoResponse(**info)
     except Exception as e:
     except Exception as e:
-        logger.error(f"Failed to get timelapse info: {e}")
+        logger.error("Failed to get timelapse info: %s", e)
         raise HTTPException(500, f"Failed to get video info: {str(e)}")
         raise HTTPException(500, f"Failed to get video info: {str(e)}")
 
 
 
 
@@ -1541,7 +1542,7 @@ async def get_timelapse_thumbnails(
             timestamps=[ts for ts, _ in thumbnails],
             timestamps=[ts for ts, _ in thumbnails],
         )
         )
     except Exception as e:
     except Exception as e:
-        logger.error(f"Failed to generate thumbnails: {e}")
+        logger.error("Failed to generate thumbnails: %s", e)
         raise HTTPException(500, f"Failed to generate thumbnails: {str(e)}")
         raise HTTPException(500, f"Failed to generate thumbnails: {str(e)}")
 
 
 
 
@@ -1647,7 +1648,7 @@ async def process_timelapse(
     except HTTPException:
     except HTTPException:
         raise
         raise
     except Exception as e:
     except Exception as e:
-        logger.error(f"Timelapse processing failed: {e}")
+        logger.error("Timelapse processing failed: %s", e)
         raise HTTPException(500, f"Processing failed: {str(e)}")
         raise HTTPException(500, f"Processing failed: {str(e)}")
     finally:
     finally:
         # Cleanup temp audio file
         # Cleanup temp audio file
@@ -1838,8 +1839,6 @@ async def get_archive_capabilities(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
 ):
     """Check what viewing capabilities are available for this 3MF file."""
     """Check what viewing capabilities are available for this 3MF file."""
-    import json
-
     import defusedxml.ElementTree as ET
     import defusedxml.ElementTree as ET
 
 
     service = ArchiveService(db)
     service = ArchiveService(db)
@@ -1883,7 +1882,7 @@ async def get_archive_capabilities(
                             if "<vertex" in content or "<mesh" in content:
                             if "<vertex" in content or "<mesh" in content:
                                 found_mesh = True
                                 found_mesh = True
                                 break
                                 break
-                        except Exception:
+                        except (KeyError, UnicodeDecodeError):
                             pass
                             pass
 
 
                 # Extract filament colors from project_settings.config
                 # Extract filament colors from project_settings.config
@@ -1925,7 +1924,7 @@ async def get_archive_capabilities(
                             for color in raw_colors:
                             for color in raw_colors:
                                 if color and isinstance(color, str):
                                 if color and isinstance(color, str):
                                     colors.append(color)
                                     colors.append(color)
-                    except Exception:
+                    except (json.JSONDecodeError, KeyError, ValueError, TypeError):
                         pass
                         pass
         except zipfile.BadZipFile:
         except zipfile.BadZipFile:
             pass
             pass
@@ -1958,7 +1957,7 @@ async def get_archive_capabilities(
                             if "<vertex" in content or "<mesh" in content:
                             if "<vertex" in content or "<mesh" in content:
                                 has_model = True
                                 has_model = True
                                 break
                                 break
-                        except Exception:
+                        except (KeyError, UnicodeDecodeError):
                             pass
                             pass
 
 
             # Extract filament colors from slice_info.config (for gcode preview)
             # Extract filament colors from slice_info.config (for gcode preview)
@@ -1992,7 +1991,7 @@ async def get_archive_capabilities(
                         max_tool = max(filament_map.keys())
                         max_tool = max(filament_map.keys())
                         for i in range(max_tool + 1):
                         for i in range(max_tool + 1):
                             slice_colors.append(filament_map.get(i, "#00AE42"))
                             slice_colors.append(filament_map.get(i, "#00AE42"))
-                except Exception:
+                except (KeyError, ValueError, ET.ParseError, UnicodeDecodeError):
                     pass
                     pass
 
 
             # Use slice_info colors if we don't have colors from source yet
             # Use slice_info colors if we don't have colors from source yet
@@ -2038,7 +2037,7 @@ async def get_archive_capabilities(
                                 for color in raw_colors:
                                 for color in raw_colors:
                                     if color and isinstance(color, str):
                                     if color and isinstance(color, str):
                                         filament_colors.append(color)
                                         filament_colors.append(color)
-                    except Exception:
+                    except (json.JSONDecodeError, KeyError, ValueError, TypeError):
                         pass
                         pass
 
 
     except zipfile.BadZipFile:
     except zipfile.BadZipFile:
@@ -2127,7 +2126,7 @@ async def get_plate_preview(
                     plate_elem = root.find(".//plate/metadata[@key='index']")
                     plate_elem = root.find(".//plate/metadata[@key='index']")
                     if plate_elem is not None:
                     if plate_elem is not None:
                         plate_num = int(plate_elem.get("value", "1"))
                         plate_num = int(plate_elem.get("value", "1"))
-                except Exception:
+                except (KeyError, ValueError, ET.ParseError, UnicodeDecodeError):
                     pass
                     pass
 
 
             # Try plate-specific image first, then fall back to plate_1
             # Try plate-specific image first, then fall back to plate_1
@@ -2234,7 +2233,7 @@ async def upload_archives_bulk(
             else:
             else:
                 errors.append({"filename": file.filename, "error": "Failed to process"})
                 errors.append({"filename": file.filename, "error": "Failed to process"})
         except Exception as e:
         except Exception as e:
-            logger.exception(f"Failed to upload archive {file.filename}: {e}")
+            logger.exception("Failed to upload archive %s: %s", file.filename, e)
             errors.append({"filename": file.filename, "error": "Failed to process file"})
             errors.append({"filename": file.filename, "error": "Failed to process file"})
         finally:
         finally:
             if temp_path.exists():
             if temp_path.exists():
@@ -2259,7 +2258,6 @@ async def get_archive_plates(
     Returns a list of plates with their index, name, thumbnail availability,
     Returns a list of plates with their index, name, thumbnail availability,
     and filament requirements. For single-plate exports, returns a single plate.
     and filament requirements. For single-plate exports, returns a single plate.
     """
     """
-    import json
     import re
     import re
 
 
     import defusedxml.ElementTree as ET
     import defusedxml.ElementTree as ET
@@ -2374,7 +2372,7 @@ async def get_archive_plates(
                                         plate_object_ids.setdefault(plater_id, [])
                                         plate_object_ids.setdefault(plater_id, [])
                                         if obj_id not in plate_object_ids[plater_id]:
                                         if obj_id not in plate_object_ids[plater_id]:
                                             plate_object_ids[plater_id].append(obj_id)
                                             plate_object_ids[plater_id].append(obj_id)
-                except Exception:
+                except (KeyError, ValueError, ET.ParseError, UnicodeDecodeError):
                     pass  # model_settings.config parsing is optional
                     pass  # model_settings.config parsing is optional
 
 
             # Parse slice_info.config for plate metadata
             # Parse slice_info.config for plate metadata
@@ -2473,7 +2471,7 @@ async def get_archive_plates(
                             names.append(obj_name)
                             names.append(obj_name)
                     if names:
                     if names:
                         plate_json_objects[plate_index] = names
                         plate_json_objects[plate_index] = names
-                except Exception:
+                except (json.JSONDecodeError, KeyError, ValueError, UnicodeDecodeError):
                     continue
                     continue
 
 
             # Build plate list
             # Build plate list
@@ -2510,8 +2508,8 @@ async def get_archive_plates(
                     }
                     }
                 )
                 )
 
 
-    except Exception as e:
-        logger.warning(f"Failed to parse plates from archive {archive_id}: {e}")
+    except (KeyError, ValueError, zipfile.BadZipFile, ET.ParseError, UnicodeDecodeError) as e:
+        logger.warning("Failed to parse plates from archive %s: %s", archive_id, e)
 
 
     return {
     return {
         "archive_id": archive_id,
         "archive_id": archive_id,
@@ -2546,7 +2544,7 @@ async def get_plate_thumbnail(
             if thumb_path in zf.namelist():
             if thumb_path in zf.namelist():
                 data = zf.read(thumb_path)
                 data = zf.read(thumb_path)
                 return Response(content=data, media_type="image/png")
                 return Response(content=data, media_type="image/png")
-    except Exception:
+    except (zipfile.BadZipFile, KeyError, OSError):
         pass
         pass
 
 
     raise HTTPException(404, f"Thumbnail for plate {plate_index} not found")
     raise HTTPException(404, f"Thumbnail for plate {plate_index} not found")
@@ -2662,8 +2660,8 @@ async def get_filament_requirements(
             # Sort by slot ID
             # Sort by slot ID
             filaments.sort(key=lambda x: x["slot_id"])
             filaments.sort(key=lambda x: x["slot_id"])
 
 
-    except Exception as e:
-        logger.warning(f"Failed to parse filament requirements from archive {archive_id}: {e}")
+    except (KeyError, ValueError, zipfile.BadZipFile, ET.ParseError, UnicodeDecodeError) as e:
+        logger.warning("Failed to parse filament requirements from archive %s: %s", archive_id, e)
 
 
     return {
     return {
         "archive_id": archive_id,
         "archive_id": archive_id,
@@ -2751,7 +2749,7 @@ async def reprint_archive(
     )
     )
 
 
     # Delete existing file if present (avoids 553 error)
     # Delete existing file if present (avoids 553 error)
-    logger.debug(f"Deleting existing file {remote_path} if present...")
+    logger.debug("Deleting existing file %s if present...", remote_path)
     delete_result = await delete_file_async(
     delete_result = await delete_file_async(
         printer.ip_address,
         printer.ip_address,
         printer.access_code,
         printer.access_code,
@@ -2759,7 +2757,7 @@ async def reprint_archive(
         socket_timeout=ftp_timeout,
         socket_timeout=ftp_timeout,
         printer_model=printer.model,
         printer_model=printer.model,
     )
     )
-    logger.debug(f"Delete result: {delete_result}")
+    logger.debug("Delete result: %s", delete_result)
 
 
     if ftp_retry_enabled:
     if ftp_retry_enabled:
         uploaded = await with_ftp_retry(
         uploaded = await with_ftp_retry(
@@ -2813,7 +2811,7 @@ async def reprint_archive(
                         plate_str = name[15:-6]  # Remove "Metadata/plate_" and ".gcode"
                         plate_str = name[15:-6]  # Remove "Metadata/plate_" and ".gcode"
                         plate_id = int(plate_str)
                         plate_id = int(plate_str)
                         break
                         break
-        except Exception:
+        except (ValueError, zipfile.BadZipFile, OSError):
             pass  # Default to plate 1 if detection fails
             pass  # Default to plate 1 if detection fails
 
 
     logger.info(
     logger.info(
@@ -2843,7 +2841,7 @@ async def reprint_archive(
     # Track who started this print (Issue #206)
     # Track who started this print (Issue #206)
     if user:
     if user:
         printer_manager.set_current_print_user(printer_id, user.id, user.username)
         printer_manager.set_current_print_user(printer_id, user.id, user.username)
-        logger.info(f"Reprint started by user: {user.username}")
+        logger.info("Reprint started by user: %s", user.username)
 
 
     return {
     return {
         "status": "printing",
         "status": "printing",

+ 8 - 8
backend/app/api/routes/auth.py

@@ -136,7 +136,7 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
 
 
                 # Create admin user FIRST (before enabling auth)
                 # Create admin user FIRST (before enabling auth)
                 try:
                 try:
-                    logger.info(f"Creating admin user: {request.admin_username}")
+                    logger.info("Creating admin user: %s", request.admin_username)
                     admin_user = User(
                     admin_user = User(
                         username=request.admin_username,
                         username=request.admin_username,
                         password_hash=get_password_hash(request.admin_password),
                         password_hash=get_password_hash(request.admin_password),
@@ -152,11 +152,11 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
                         logger.info("Added new admin user to Administrators group")
                         logger.info("Added new admin user to Administrators group")
 
 
                     db.add(admin_user)
                     db.add(admin_user)
-                    logger.info(f"Admin user added to session: {request.admin_username}")
+                    logger.info("Admin user added to session: %s", request.admin_username)
                     admin_created = True
                     admin_created = True
                 except Exception as e:
                 except Exception as e:
                     await db.rollback()
                     await db.rollback()
-                    logger.error(f"Failed to create admin user: {e}", exc_info=True)
+                    logger.error("Failed to create admin user: %s", e, exc_info=True)
                     raise HTTPException(
                     raise HTTPException(
                         status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
                         status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
                         detail=f"Failed to create admin user: {str(e)}",
                         detail=f"Failed to create admin user: {str(e)}",
@@ -169,14 +169,14 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
 
 
         if admin_created:
         if admin_created:
             await db.refresh(admin_user)
             await db.refresh(admin_user)
-            logger.info(f"Admin user created successfully: {admin_user.id}")
+            logger.info("Admin user created successfully: %s", admin_user.id)
 
 
-        logger.info(f"Setup completed: auth_enabled={request.auth_enabled}, admin_created={admin_created}")
+        logger.info("Setup completed: auth_enabled=%s, admin_created=%s", request.auth_enabled, admin_created)
         return SetupResponse(auth_enabled=request.auth_enabled, admin_created=admin_created)
         return SetupResponse(auth_enabled=request.auth_enabled, admin_created=admin_created)
     except HTTPException:
     except HTTPException:
         raise
         raise
     except Exception as e:
     except Exception as e:
-        logger.error(f"Setup error: {e}", exc_info=True)
+        logger.error("Setup error: %s", e, exc_info=True)
         await db.rollback()
         await db.rollback()
         raise HTTPException(
         raise HTTPException(
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -218,11 +218,11 @@ async def disable_auth(
     try:
     try:
         await set_auth_enabled(db, False)
         await set_auth_enabled(db, False)
         await db.commit()
         await db.commit()
-        logger.info(f"Authentication disabled by admin user: {user.username}")
+        logger.info("Authentication disabled by admin user: %s", user.username)
         return {"message": "Authentication disabled successfully", "auth_enabled": False}
         return {"message": "Authentication disabled successfully", "auth_enabled": False}
     except Exception as e:
     except Exception as e:
         await db.rollback()
         await db.rollback()
-        logger.error(f"Failed to disable authentication: {e}", exc_info=True)
+        logger.error("Failed to disable authentication: %s", e, exc_info=True)
         raise HTTPException(
         raise HTTPException(
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
             detail=f"Failed to disable authentication: {str(e)}",
             detail=f"Failed to disable authentication: {str(e)}",

+ 41 - 37
backend/app/api/routes/camera.py

@@ -76,11 +76,11 @@ async def generate_chamber_mjpeg_stream(
 
 
     This connects to port 6000 and reads JPEG frames using the Bambu binary protocol.
     This connects to port 6000 and reads JPEG frames using the Bambu binary protocol.
     """
     """
-    logger.info(f"Starting chamber image stream for {ip_address} (stream_id={stream_id}, model={model})")
+    logger.info("Starting chamber image stream for %s (stream_id=%s, model=%s)", ip_address, stream_id, model)
 
 
     connection = await generate_chamber_image_stream(ip_address, access_code, fps)
     connection = await generate_chamber_image_stream(ip_address, access_code, fps)
     if connection is None:
     if connection is None:
-        logger.error(f"Failed to connect to chamber image stream for {ip_address}")
+        logger.error("Failed to connect to chamber image stream for %s", ip_address)
         yield (
         yield (
             b"--frame\r\n"
             b"--frame\r\n"
             b"Content-Type: text/plain\r\n\r\n"
             b"Content-Type: text/plain\r\n\r\n"
@@ -101,13 +101,13 @@ async def generate_chamber_mjpeg_stream(
         while True:
         while True:
             # Check if client disconnected
             # Check if client disconnected
             if disconnect_event and disconnect_event.is_set():
             if disconnect_event and disconnect_event.is_set():
-                logger.info(f"Client disconnected, stopping chamber stream {stream_id}")
+                logger.info("Client disconnected, stopping chamber stream %s", stream_id)
                 break
                 break
 
 
             # Read next frame
             # Read next frame
             frame = await read_next_chamber_frame(reader, timeout=30.0)
             frame = await read_next_chamber_frame(reader, timeout=30.0)
             if frame is None:
             if frame is None:
-                logger.warning(f"Chamber image stream ended for {stream_id}")
+                logger.warning("Chamber image stream ended for %s", stream_id)
                 break
                 break
 
 
             # Save frame to buffer for photo capture and track timestamp
             # Save frame to buffer for photo capture and track timestamp
@@ -132,11 +132,11 @@ async def generate_chamber_mjpeg_stream(
             )
             )
 
 
     except asyncio.CancelledError:
     except asyncio.CancelledError:
-        logger.info(f"Chamber image stream cancelled (stream_id={stream_id})")
+        logger.info("Chamber image stream cancelled (stream_id=%s)", stream_id)
     except GeneratorExit:
     except GeneratorExit:
-        logger.info(f"Chamber image stream generator exit (stream_id={stream_id})")
+        logger.info("Chamber image stream generator exit (stream_id=%s)", stream_id)
     except Exception as e:
     except Exception as e:
-        logger.exception(f"Chamber image stream error: {e}")
+        logger.exception("Chamber image stream error: %s", e)
     finally:
     finally:
         # Remove from active streams
         # Remove from active streams
         if stream_id and stream_id in _active_chamber_streams:
         if stream_id and stream_id in _active_chamber_streams:
@@ -152,9 +152,9 @@ async def generate_chamber_mjpeg_stream(
         try:
         try:
             writer.close()
             writer.close()
             await writer.wait_closed()
             await writer.wait_closed()
-        except Exception:
+        except OSError:
             pass
             pass
-        logger.info(f"Chamber image stream stopped for {ip_address} (stream_id={stream_id})")
+        logger.info("Chamber image stream stopped for %s (stream_id=%s)", ip_address, stream_id)
 
 
 
 
 async def generate_rtsp_mjpeg_stream(
 async def generate_rtsp_mjpeg_stream(
@@ -212,8 +212,10 @@ async def generate_rtsp_mjpeg_stream(
         "-",  # Output to stdout
         "-",  # Output to stdout
     ]
     ]
 
 
-    logger.info(f"Starting RTSP camera stream for {ip_address} (stream_id={stream_id}, model={model}, fps={fps})")
-    logger.debug(f"ffmpeg command: {ffmpeg} ... (url hidden)")
+    logger.info(
+        "Starting RTSP camera stream for %s (stream_id=%s, model=%s, fps=%s)", ip_address, stream_id, model, fps
+    )
+    logger.debug("ffmpeg command: %s ... (url hidden)", ffmpeg)
 
 
     process = None
     process = None
     try:
     try:
@@ -231,7 +233,7 @@ async def generate_rtsp_mjpeg_stream(
         await asyncio.sleep(0.5)
         await asyncio.sleep(0.5)
         if process.returncode is not None:
         if process.returncode is not None:
             stderr = await process.stderr.read()
             stderr = await process.stderr.read()
-            logger.error(f"ffmpeg failed immediately: {stderr.decode()}")
+            logger.error("ffmpeg failed immediately: %s", stderr.decode())
             yield (
             yield (
                 b"--frame\r\n"
                 b"--frame\r\n"
                 b"Content-Type: text/plain\r\n\r\n"
                 b"Content-Type: text/plain\r\n\r\n"
@@ -248,7 +250,7 @@ async def generate_rtsp_mjpeg_stream(
         while True:
         while True:
             # Check if client disconnected
             # Check if client disconnected
             if disconnect_event and disconnect_event.is_set():
             if disconnect_event and disconnect_event.is_set():
-                logger.info(f"Client disconnected, stopping stream {stream_id}")
+                logger.info("Client disconnected, stopping stream %s", stream_id)
                 break
                 break
 
 
             try:
             try:
@@ -301,21 +303,21 @@ async def generate_rtsp_mjpeg_stream(
                 logger.warning("Camera stream read timeout")
                 logger.warning("Camera stream read timeout")
                 break
                 break
             except asyncio.CancelledError:
             except asyncio.CancelledError:
-                logger.info(f"Camera stream cancelled (stream_id={stream_id})")
+                logger.info("Camera stream cancelled (stream_id=%s)", stream_id)
                 break
                 break
             except GeneratorExit:
             except GeneratorExit:
-                logger.info(f"Camera stream generator exit (stream_id={stream_id})")
+                logger.info("Camera stream generator exit (stream_id=%s)", stream_id)
                 break
                 break
 
 
     except FileNotFoundError:
     except FileNotFoundError:
         logger.error("ffmpeg not found - camera streaming requires ffmpeg")
         logger.error("ffmpeg not found - camera streaming requires ffmpeg")
         yield (b"--frame\r\nContent-Type: text/plain\r\n\r\nError: ffmpeg not installed\r\n")
         yield (b"--frame\r\nContent-Type: text/plain\r\n\r\nError: ffmpeg not installed\r\n")
     except asyncio.CancelledError:
     except asyncio.CancelledError:
-        logger.info(f"Camera stream task cancelled (stream_id={stream_id})")
+        logger.info("Camera stream task cancelled (stream_id=%s)", stream_id)
     except GeneratorExit:
     except GeneratorExit:
-        logger.info(f"Camera stream generator closed (stream_id={stream_id})")
+        logger.info("Camera stream generator closed (stream_id=%s)", stream_id)
     except Exception as e:
     except Exception as e:
-        logger.exception(f"Camera stream error: {e}")
+        logger.exception("Camera stream error: %s", e)
     finally:
     finally:
         # Remove from active streams
         # Remove from active streams
         if stream_id and stream_id in _active_streams:
         if stream_id and stream_id in _active_streams:
@@ -328,20 +330,20 @@ async def generate_rtsp_mjpeg_stream(
             _stream_start_times.pop(printer_id, None)
             _stream_start_times.pop(printer_id, None)
 
 
         if process and process.returncode is None:
         if process and process.returncode is None:
-            logger.info(f"Terminating ffmpeg process for stream {stream_id}")
+            logger.info("Terminating ffmpeg process for stream %s", stream_id)
             try:
             try:
                 process.terminate()
                 process.terminate()
                 try:
                 try:
                     await asyncio.wait_for(process.wait(), timeout=2.0)
                     await asyncio.wait_for(process.wait(), timeout=2.0)
                 except TimeoutError:
                 except TimeoutError:
-                    logger.warning(f"ffmpeg didn't terminate gracefully, killing (stream_id={stream_id})")
+                    logger.warning("ffmpeg didn't terminate gracefully, killing (stream_id=%s)", stream_id)
                     process.kill()
                     process.kill()
                     await process.wait()
                     await process.wait()
             except ProcessLookupError:
             except ProcessLookupError:
                 pass  # Process already dead
                 pass  # Process already dead
-            except Exception as e:
-                logger.warning(f"Error terminating ffmpeg: {e}")
-            logger.info(f"Camera stream stopped for {ip_address} (stream_id={stream_id})")
+            except OSError as e:
+                logger.warning("Error terminating ffmpeg: %s", e)
+            logger.info("Camera stream stopped for %s (stream_id=%s)", ip_address, stream_id)
 
 
 
 
 @router.get("/{printer_id}/camera/stream")
 @router.get("/{printer_id}/camera/stream")
@@ -379,7 +381,9 @@ async def camera_stream(
 
 
         # Limit external camera FPS to reduce browser load
         # Limit external camera FPS to reduce browser load
         fps = min(max(fps, 1), 15)
         fps = min(max(fps, 1), 15)
-        logger.info(f"Using external camera ({printer.external_camera_type}) for printer {printer_id} at {fps} fps")
+        logger.info(
+            "Using external camera (%s) for printer %s at %s fps", printer.external_camera_type, printer_id, fps
+        )
 
 
         # Track stream start
         # Track stream start
         _stream_start_times[printer_id] = time.time()
         _stream_start_times[printer_id] = time.time()
@@ -403,7 +407,7 @@ async def camera_stream(
                     yield frame
                     yield frame
             finally:
             finally:
                 _active_external_streams.discard(printer_id)
                 _active_external_streams.discard(printer_id)
-                logger.info(f"External camera stream ended for printer {printer_id}")
+                logger.info("External camera stream ended for printer %s", printer_id)
 
 
         return StreamingResponse(
         return StreamingResponse(
             external_stream_wrapper(),
             external_stream_wrapper(),
@@ -430,10 +434,10 @@ async def camera_stream(
     # Choose the appropriate stream generator based on model
     # Choose the appropriate stream generator based on model
     if is_chamber_image_model(printer.model):
     if is_chamber_image_model(printer.model):
         stream_generator = generate_chamber_mjpeg_stream
         stream_generator = generate_chamber_mjpeg_stream
-        logger.info(f"Using chamber image protocol for {printer.model}")
+        logger.info("Using chamber image protocol for %s", printer.model)
     else:
     else:
         stream_generator = generate_rtsp_mjpeg_stream
         stream_generator = generate_rtsp_mjpeg_stream
-        logger.info(f"Using RTSP protocol for {printer.model}")
+        logger.info("Using RTSP protocol for %s", printer.model)
 
 
     # Track stream start time
     # Track stream start time
     import time
     import time
@@ -454,15 +458,15 @@ async def camera_stream(
             ):
             ):
                 # Check if client is still connected
                 # Check if client is still connected
                 if await request.is_disconnected():
                 if await request.is_disconnected():
-                    logger.info(f"Client disconnected detected for stream {stream_id}")
+                    logger.info("Client disconnected detected for stream %s", stream_id)
                     disconnect_event.set()
                     disconnect_event.set()
                     break
                     break
                 yield chunk
                 yield chunk
         except asyncio.CancelledError:
         except asyncio.CancelledError:
-            logger.info(f"Stream {stream_id} cancelled")
+            logger.info("Stream %s cancelled", stream_id)
             disconnect_event.set()
             disconnect_event.set()
         except GeneratorExit:
         except GeneratorExit:
-            logger.info(f"Stream {stream_id} generator closed")
+            logger.info("Stream %s generator closed", stream_id)
             disconnect_event.set()
             disconnect_event.set()
         finally:
         finally:
             disconnect_event.set()
             disconnect_event.set()
@@ -501,9 +505,9 @@ async def stop_camera_stream(
                 try:
                 try:
                     process.terminate()
                     process.terminate()
                     stopped += 1
                     stopped += 1
-                    logger.info(f"Terminated ffmpeg process for stream {stream_id}")
-                except Exception as e:
-                    logger.warning(f"Error stopping stream {stream_id}: {e}")
+                    logger.info("Terminated ffmpeg process for stream %s", stream_id)
+                except OSError as e:
+                    logger.warning("Error stopping stream %s: %s", stream_id, e)
 
 
     for stream_id in to_remove:
     for stream_id in to_remove:
         _active_streams.pop(stream_id, None)
         _active_streams.pop(stream_id, None)
@@ -516,14 +520,14 @@ async def stop_camera_stream(
             try:
             try:
                 writer.close()
                 writer.close()
                 stopped += 1
                 stopped += 1
-                logger.info(f"Closed chamber image connection for stream {stream_id}")
-            except Exception as e:
-                logger.warning(f"Error stopping chamber stream {stream_id}: {e}")
+                logger.info("Closed chamber image connection for stream %s", stream_id)
+            except OSError as e:
+                logger.warning("Error stopping chamber stream %s: %s", stream_id, e)
 
 
     for stream_id in to_remove_chamber:
     for stream_id in to_remove_chamber:
         _active_chamber_streams.pop(stream_id, None)
         _active_chamber_streams.pop(stream_id, None)
 
 
-    logger.info(f"Stopped {stopped} camera stream(s) for printer {printer_id}")
+    logger.info("Stopped %s camera stream(s) for printer %s", stopped, printer_id)
     return {"stopped": stopped}
     return {"stopped": stopped}
 
 
 
 

+ 2 - 2
backend/app/api/routes/cloud.py

@@ -349,7 +349,7 @@ async def get_filament_info(
     """
     """
     import time
     import time
 
 
-    logger.info(f"get_filament_info called with {len(setting_ids)} IDs: {setting_ids}")
+    logger.info("get_filament_info called with %s IDs: %s", len(setting_ids), setting_ids)
 
 
     global _filament_cache, _filament_cache_time
     global _filament_cache, _filament_cache_time
 
 
@@ -512,7 +512,7 @@ async def get_firmware_updates(
                     )
                     )
                 )
                 )
             except BambuCloudError as e:
             except BambuCloudError as e:
-                logger.warning(f"Failed to get firmware info for {device_name}: {e}")
+                logger.warning("Failed to get firmware info for %s: %s", device_name, e)
                 # Still include device but with unknown firmware status
                 # Still include device but with unknown firmware status
                 updates.append(
                 updates.append(
                     FirmwareUpdateInfo(
                     FirmwareUpdateInfo(

+ 6 - 6
backend/app/api/routes/external_links.py

@@ -65,7 +65,7 @@ async def create_external_link(
     await db.commit()
     await db.commit()
     await db.refresh(link)
     await db.refresh(link)
 
 
-    logger.info(f"Created external link: {link.name} -> {link.url}")
+    logger.info("Created external link: %s -> %s", link.name, link.url)
 
 
     return link
     return link
 
 
@@ -108,7 +108,7 @@ async def update_external_link(
     await db.commit()
     await db.commit()
     await db.refresh(link)
     await db.refresh(link)
 
 
-    logger.info(f"Updated external link: {link.name}")
+    logger.info("Updated external link: %s", link.name)
 
 
     return link
     return link
 
 
@@ -130,7 +130,7 @@ async def delete_external_link(
     await db.delete(link)
     await db.delete(link)
     await db.commit()
     await db.commit()
 
 
-    logger.info(f"Deleted external link: {name}")
+    logger.info("Deleted external link: %s", name)
 
 
     return {"message": f"External link '{name}' deleted"}
     return {"message": f"External link '{name}' deleted"}
 
 
@@ -155,7 +155,7 @@ async def reorder_external_links(
     result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id))
     result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id))
     links = result.scalars().all()
     links = result.scalars().all()
 
 
-    logger.info(f"Reordered {len(reorder_data.ids)} external links")
+    logger.info("Reordered %s external links", len(reorder_data.ids))
 
 
     return links
     return links
 
 
@@ -205,7 +205,7 @@ async def upload_icon(
     await db.commit()
     await db.commit()
     await db.refresh(link)
     await db.refresh(link)
 
 
-    logger.info(f"Uploaded custom icon for link {link.name}: {filename}")
+    logger.info("Uploaded custom icon for link %s: %s", link.name, filename)
 
 
     return link
     return link
 
 
@@ -230,7 +230,7 @@ async def delete_icon(
         link.custom_icon = None
         link.custom_icon = None
         await db.commit()
         await db.commit()
         await db.refresh(link)
         await db.refresh(link)
-        logger.info(f"Deleted custom icon for link {link.name}")
+        logger.info("Deleted custom icon for link %s", link.name)
 
 
     return link
     return link
 
 

+ 4 - 4
backend/app/api/routes/github_backup.py

@@ -97,7 +97,7 @@ async def save_config(
         else:
         else:
             config.next_scheduled_run = None
             config.next_scheduled_run = None
 
 
-        logger.info(f"Updated GitHub backup config: {config.repository_url}")
+        logger.info("Updated GitHub backup config: %s", config.repository_url)
     else:
     else:
         # Create new
         # Create new
         config = GitHubBackupConfig(
         config = GitHubBackupConfig(
@@ -116,7 +116,7 @@ async def save_config(
             config.next_scheduled_run = github_backup_service._calculate_next_run(config.schedule_type)
             config.next_scheduled_run = github_backup_service._calculate_next_run(config.schedule_type)
 
 
         db.add(config)
         db.add(config)
-        logger.info(f"Created GitHub backup config: {config.repository_url}")
+        logger.info("Created GitHub backup config: %s", config.repository_url)
 
 
     await db.commit()
     await db.commit()
     await db.refresh(config)
     await db.refresh(config)
@@ -155,7 +155,7 @@ async def update_config(
     await db.commit()
     await db.commit()
     await db.refresh(config)
     await db.refresh(config)
 
 
-    logger.info(f"Updated GitHub backup config: {config.repository_url}")
+    logger.info("Updated GitHub backup config: %s", config.repository_url)
 
 
     return _config_to_response(config)
     return _config_to_response(config)
 
 
@@ -337,6 +337,6 @@ async def clear_logs(
     await db.commit()
     await db.commit()
 
 
     deleted_count = delete_result.rowcount
     deleted_count = delete_result.rowcount
-    logger.info(f"Deleted {deleted_count} GitHub backup logs (kept {keep_last})")
+    logger.info("Deleted %s GitHub backup logs (kept %s)", deleted_count, keep_last)
 
 
     return {"deleted": deleted_count, "message": f"Deleted {deleted_count} logs"}
     return {"deleted": deleted_count, "message": f"Deleted {deleted_count} logs"}

+ 4 - 4
backend/app/api/routes/kprofiles.py

@@ -118,7 +118,7 @@ async def set_kprofile(
 
 
     if is_edit and is_h2d:
     if is_edit and is_h2d:
         # H2D in-place edit: use cali_idx with slot_id=0 and empty setting_id
         # H2D in-place edit: use cali_idx with slot_id=0 and empty setting_id
-        logger.info(f"[API] H2D in-place edit: cali_idx={profile.slot_id}")
+        logger.info("[API] H2D in-place edit: cali_idx=%s", profile.slot_id)
         success = client.set_kprofile(
         success = client.set_kprofile(
             filament_id=profile.filament_id,
             filament_id=profile.filament_id,
             name=profile.name,
             name=profile.name,
@@ -132,7 +132,7 @@ async def set_kprofile(
         )
         )
     elif is_edit:
     elif is_edit:
         # Non-H2D edit: use delete + add approach
         # Non-H2D edit: use delete + add approach
-        logger.info(f"[API] Edit: deleting existing profile slot_id={profile.slot_id}")
+        logger.info("[API] Edit: deleting existing profile slot_id=%s", profile.slot_id)
         delete_success = client.delete_kprofile(
         delete_success = client.delete_kprofile(
             cali_idx=profile.slot_id,
             cali_idx=profile.slot_id,
             filament_id=profile.filament_id,
             filament_id=profile.filament_id,
@@ -197,9 +197,9 @@ async def set_kprofiles_batch(
     if not profiles:
     if not profiles:
         raise HTTPException(400, "No profiles provided")
         raise HTTPException(400, "No profiles provided")
 
 
-    logger.info(f"[API] set_kprofiles_batch: printer={printer_id}, {len(profiles)} profiles")
+    logger.info("[API] set_kprofiles_batch: printer=%s, %s profiles", printer_id, len(profiles))
     for p in profiles:
     for p in profiles:
-        logger.info(f"  - extruder_id={p.extruder_id}, name={p.name}, k_value={p.k_value}")
+        logger.info("  - extruder_id=%s, name=%s, k_value=%s", p.extruder_id, p.name, p.k_value)
 
 
     # Check printer exists
     # Check printer exists
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))

+ 41 - 39
backend/app/api/routes/library.py

@@ -1,12 +1,14 @@
 """API routes for File Manager (Library) functionality."""
 """API routes for File Manager (Library) functionality."""
 
 
 import base64
 import base64
+import binascii
 import hashlib
 import hashlib
 import logging
 import logging
 import os
 import os
 import re
 import re
 import shutil
 import shutil
 import uuid
 import uuid
+import zipfile
 from pathlib import Path
 from pathlib import Path
 
 
 from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
 from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
@@ -159,7 +161,7 @@ def extract_gcode_thumbnail(file_path: Path) -> bytes | None:
                         # Only keep if this is the best size or first valid thumbnail
                         # Only keep if this is the best size or first valid thumbnail
                         if thumbnail_data is None or best_size > 0:
                         if thumbnail_data is None or best_size > 0:
                             thumbnail_data = decoded
                             thumbnail_data = decoded
-                    except Exception:
+                    except (binascii.Error, ValueError):
                         pass
                         pass
                 in_thumbnail = False
                 in_thumbnail = False
                 thumbnail_lines = []
                 thumbnail_lines = []
@@ -173,8 +175,8 @@ def extract_gcode_thumbnail(file_path: Path) -> bytes | None:
                     thumbnail_lines.append(data_line)
                     thumbnail_lines.append(data_line)
 
 
         return thumbnail_data
         return thumbnail_data
-    except Exception as e:
-        logger.warning(f"Failed to extract gcode thumbnail: {e}")
+    except OSError as e:
+        logger.warning("Failed to extract gcode thumbnail: %s", e)
         return None
         return None
 
 
 
 
@@ -219,11 +221,11 @@ def create_image_thumbnail(file_path: Path, thumbnails_dir: Path, max_size: int
                 thumb_path = thumbnails_dir / thumb_filename
                 thumb_path = thumbnails_dir / thumb_filename
                 shutil.copy2(file_path, thumb_path)
                 shutil.copy2(file_path, thumb_path)
                 return str(thumb_path)
                 return str(thumb_path)
-        except Exception:
+        except OSError:
             pass
             pass
         return None
         return None
     except Exception as e:
     except Exception as e:
-        logger.warning(f"Failed to create image thumbnail: {e}")
+        logger.warning("Failed to create image thumbnail: %s", e)
         return None
         return None
 
 
 
 
@@ -595,8 +597,8 @@ async def delete_folder(
                     os.remove(file_path)
                     os.remove(file_path)
                 if thumb_path and os.path.exists(thumb_path):
                 if thumb_path and os.path.exists(thumb_path):
                     os.remove(thumb_path)
                     os.remove(thumb_path)
-            except Exception as e:
-                logger.warning(f"Failed to delete file: {e}")
+            except OSError as e:
+                logger.warning("Failed to delete file: %s", e)
 
 
         # Get child folders and recurse
         # Get child folders and recurse
         children_result = await db.execute(select(LibraryFolder.id).where(LibraryFolder.parent_id == fid))
         children_result = await db.execute(select(LibraryFolder.id).where(LibraryFolder.parent_id == fid))
@@ -772,8 +774,8 @@ async def upload_file(
                     return obj
                     return obj
 
 
                 metadata = clean_metadata(raw_metadata)
                 metadata = clean_metadata(raw_metadata)
-            except Exception as e:
-                logger.warning(f"Failed to parse 3MF: {e}")
+            except (KeyError, ValueError, zipfile.BadZipFile, OSError) as e:
+                logger.warning("Failed to parse 3MF: %s", e)
 
 
         elif ext == ".gcode":
         elif ext == ".gcode":
             # Extract embedded thumbnail from gcode
             # Extract embedded thumbnail from gcode
@@ -785,8 +787,8 @@ async def upload_file(
                     with open(thumb_path, "wb") as f:
                     with open(thumb_path, "wb") as f:
                         f.write(thumbnail_data)
                         f.write(thumbnail_data)
                     thumbnail_path = str(thumb_path)
                     thumbnail_path = str(thumb_path)
-            except Exception as e:
-                logger.warning(f"Failed to extract gcode thumbnail: {e}")
+            except OSError as e:
+                logger.warning("Failed to extract gcode thumbnail: %s", e)
 
 
         elif ext.lower() in IMAGE_EXTENSIONS:
         elif ext.lower() in IMAGE_EXTENSIONS:
             # For image files, create a thumbnail from the image itself
             # For image files, create a thumbnail from the image itself
@@ -825,7 +827,7 @@ async def upload_file(
     except HTTPException:
     except HTTPException:
         raise
         raise
     except Exception as e:
     except Exception as e:
-        logger.error(f"Upload failed for {file.filename}: {e}", exc_info=True)
+        logger.error("Upload failed for %s: %s", file.filename, e, exc_info=True)
         raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
         raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
 
 
 
 
@@ -866,7 +868,7 @@ async def extract_zip_file(
             content = await file.read()
             content = await file.read()
             tmp.write(content)
             tmp.write(content)
             tmp_path = tmp.name
             tmp_path = tmp.name
-    except Exception as e:
+    except OSError as e:
         raise HTTPException(status_code=500, detail=f"Failed to save ZIP file: {str(e)}")
         raise HTTPException(status_code=500, detail=f"Failed to save ZIP file: {str(e)}")
 
 
     extracted_files: list[ZipExtractResult] = []
     extracted_files: list[ZipExtractResult] = []
@@ -892,7 +894,7 @@ async def extract_zip_file(
         existing_folder = existing.scalar_one_or_none()
         existing_folder = existing.scalar_one_or_none()
         if existing_folder:
         if existing_folder:
             zip_folder_id = existing_folder.id
             zip_folder_id = existing_folder.id
-            logger.info(f"Reusing existing folder '{zip_folder_name}' with id={zip_folder_id}")
+            logger.info("Reusing existing folder '%s' with id=%s", zip_folder_name, zip_folder_id)
         else:
         else:
             # Create folder
             # Create folder
             new_folder = LibraryFolder(name=zip_folder_name, parent_id=folder_id)
             new_folder = LibraryFolder(name=zip_folder_name, parent_id=folder_id)
@@ -901,7 +903,7 @@ async def extract_zip_file(
             await db.commit()  # Commit folder creation immediately
             await db.commit()  # Commit folder creation immediately
             zip_folder_id = new_folder.id
             zip_folder_id = new_folder.id
             folders_created += 1
             folders_created += 1
-            logger.info(f"Created new folder '{zip_folder_name}' with id={zip_folder_id}")
+            logger.info("Created new folder '%s' with id=%s", zip_folder_name, zip_folder_id)
 
 
     try:
     try:
         with zipfile.ZipFile(tmp_path, "r") as zf:
         with zipfile.ZipFile(tmp_path, "r") as zf:
@@ -1012,8 +1014,8 @@ async def extract_zip_file(
                                 return obj
                                 return obj
 
 
                             metadata = clean_metadata(raw_metadata)
                             metadata = clean_metadata(raw_metadata)
-                        except Exception as e:
-                            logger.warning(f"Failed to parse 3MF from ZIP: {e}")
+                        except (KeyError, ValueError, zipfile.BadZipFile, OSError) as e:
+                            logger.warning("Failed to parse 3MF from ZIP: %s", e)
 
 
                     elif ext == ".gcode":
                     elif ext == ".gcode":
                         try:
                         try:
@@ -1024,8 +1026,8 @@ async def extract_zip_file(
                                 with open(thumb_path, "wb") as f:
                                 with open(thumb_path, "wb") as f:
                                     f.write(thumbnail_data)
                                     f.write(thumbnail_data)
                                 thumbnail_path = str(thumb_path)
                                 thumbnail_path = str(thumb_path)
-                        except Exception as e:
-                            logger.warning(f"Failed to extract gcode thumbnail from ZIP: {e}")
+                        except OSError as e:
+                            logger.warning("Failed to extract gcode thumbnail from ZIP: %s", e)
 
 
                     elif ext.lower() in IMAGE_EXTENSIONS:
                     elif ext.lower() in IMAGE_EXTENSIONS:
                         thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir)
                         thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir)
@@ -1064,7 +1066,7 @@ async def extract_zip_file(
                     await db.commit()
                     await db.commit()
 
 
                 except Exception as e:
                 except Exception as e:
-                    logger.error(f"Failed to extract {zip_path}: {e}")
+                    logger.error("Failed to extract %s: %s", zip_path, e)
                     errors.append(ZipExtractError(filename=os.path.basename(zip_path), error=str(e)))
                     errors.append(ZipExtractError(filename=os.path.basename(zip_path), error=str(e)))
                     # Rollback the failed file but continue with others
                     # Rollback the failed file but continue with others
                     await db.rollback()
                     await db.rollback()
@@ -1079,13 +1081,13 @@ async def extract_zip_file(
     except zipfile.BadZipFile:
     except zipfile.BadZipFile:
         raise HTTPException(status_code=400, detail="Invalid or corrupted ZIP file")
         raise HTTPException(status_code=400, detail="Invalid or corrupted ZIP file")
     except Exception as e:
     except Exception as e:
-        logger.error(f"ZIP extraction failed: {e}", exc_info=True)
+        logger.error("ZIP extraction failed: %s", e, exc_info=True)
         raise HTTPException(status_code=500, detail=f"ZIP extraction failed: {str(e)}")
         raise HTTPException(status_code=500, detail=f"ZIP extraction failed: {str(e)}")
     finally:
     finally:
         # Clean up temp file
         # Clean up temp file
         try:
         try:
             os.unlink(tmp_path)
             os.unlink(tmp_path)
-        except Exception:
+        except OSError:
             pass
             pass
 
 
 
 
@@ -1184,7 +1186,7 @@ async def batch_generate_stl_thumbnails(
                 )
                 )
                 failed += 1
                 failed += 1
         except Exception as e:
         except Exception as e:
-            logger.error(f"Failed to generate thumbnail for {stl_file.filename}: {e}")
+            logger.error("Failed to generate thumbnail for %s: %s", stl_file.filename, e)
             results.append(
             results.append(
                 BatchThumbnailResult(
                 BatchThumbnailResult(
                     file_id=stl_file.id,
                     file_id=stl_file.id,
@@ -1291,7 +1293,7 @@ async def add_files_to_queue(
             )
             )
 
 
         except Exception as e:
         except Exception as e:
-            logger.exception(f"Error adding file {file_id} to queue")
+            logger.exception("Error adding file %s to queue", file_id)
             errors.append(AddToQueueError(file_id=file_id, filename=lib_file.filename, error=str(e)))
             errors.append(AddToQueueError(file_id=file_id, filename=lib_file.filename, error=str(e)))
 
 
     await db.commit()
     await db.commit()
@@ -1424,7 +1426,7 @@ async def get_library_file_plates(
                                         plate_object_ids.setdefault(plater_id, [])
                                         plate_object_ids.setdefault(plater_id, [])
                                         if obj_id not in plate_object_ids[plater_id]:
                                         if obj_id not in plate_object_ids[plater_id]:
                                             plate_object_ids[plater_id].append(obj_id)
                                             plate_object_ids[plater_id].append(obj_id)
-                except Exception:
+                except (KeyError, ValueError, ET.ParseError, UnicodeDecodeError):
                     pass
                     pass
 
 
             # Parse slice_info.config for plate metadata
             # Parse slice_info.config for plate metadata
@@ -1517,7 +1519,7 @@ async def get_library_file_plates(
                             names.append(obj_name)
                             names.append(obj_name)
                     if names:
                     if names:
                         plate_json_objects[plate_index] = names
                         plate_json_objects[plate_index] = names
-                except Exception:
+                except (json.JSONDecodeError, KeyError, ValueError, UnicodeDecodeError):
                     continue
                     continue
 
 
             # Build plate list
             # Build plate list
@@ -1554,8 +1556,8 @@ async def get_library_file_plates(
                     }
                     }
                 )
                 )
 
 
-    except Exception as e:
-        logger.warning(f"Failed to parse plates from library file {file_id}: {e}")
+    except (KeyError, ValueError, zipfile.BadZipFile, ET.ParseError, UnicodeDecodeError) as e:
+        logger.warning("Failed to parse plates from library file %s: %s", file_id, e)
 
 
     return {
     return {
         "file_id": file_id,
         "file_id": file_id,
@@ -1592,7 +1594,7 @@ async def get_library_file_plate_thumbnail(
             if thumb_path in zf.namelist():
             if thumb_path in zf.namelist():
                 data = zf.read(thumb_path)
                 data = zf.read(thumb_path)
                 return Response(content=data, media_type="image/png")
                 return Response(content=data, media_type="image/png")
-    except Exception:
+    except (zipfile.BadZipFile, KeyError, OSError):
         pass
         pass
 
 
     raise HTTPException(status_code=404, detail=f"Thumbnail for plate {plate_index} not found")
     raise HTTPException(status_code=404, detail=f"Thumbnail for plate {plate_index} not found")
@@ -1716,8 +1718,8 @@ async def get_library_file_filament_requirements(
             # Sort by slot ID
             # Sort by slot ID
             filaments.sort(key=lambda x: x["slot_id"])
             filaments.sort(key=lambda x: x["slot_id"])
 
 
-    except Exception as e:
-        logger.warning(f"Failed to parse filament requirements from library file {file_id}: {e}")
+    except (KeyError, ValueError, zipfile.BadZipFile, ET.ParseError, UnicodeDecodeError) as e:
+        logger.warning("Failed to parse filament requirements from library file %s: %s", file_id, e)
 
 
     return {
     return {
         "file_id": file_id,
         "file_id": file_id,
@@ -1821,7 +1823,7 @@ async def print_library_file(
     )
     )
 
 
     # Delete existing file if present (avoids 553 error)
     # Delete existing file if present (avoids 553 error)
-    logger.debug(f"Deleting existing file {remote_path} if present...")
+    logger.debug("Deleting existing file %s if present...", remote_path)
     delete_result = await delete_file_async(
     delete_result = await delete_file_async(
         printer.ip_address,
         printer.ip_address,
         printer.access_code,
         printer.access_code,
@@ -1829,7 +1831,7 @@ async def print_library_file(
         socket_timeout=ftp_timeout,
         socket_timeout=ftp_timeout,
         printer_model=printer.model,
         printer_model=printer.model,
     )
     )
-    logger.debug(f"Delete result: {delete_result}")
+    logger.debug("Delete result: %s", delete_result)
 
 
     # Upload file to printer
     # Upload file to printer
     if ftp_retry_enabled:
     if ftp_retry_enabled:
@@ -1882,7 +1884,7 @@ async def print_library_file(
                         plate_str = name[15:-6]
                         plate_str = name[15:-6]
                         plate_id = int(plate_str)
                         plate_id = int(plate_str)
                         break
                         break
-        except Exception:
+        except (ValueError, zipfile.BadZipFile, OSError):
             pass
             pass
 
 
     logger.info(
     logger.info(
@@ -2103,8 +2105,8 @@ async def delete_file(
             abs_file_path.unlink()
             abs_file_path.unlink()
         if abs_thumb_path and abs_thumb_path.exists():
         if abs_thumb_path and abs_thumb_path.exists():
             abs_thumb_path.unlink()
             abs_thumb_path.unlink()
-    except Exception as e:
-        logger.warning(f"Failed to delete file from disk: {e}")
+    except OSError as e:
+        logger.warning("Failed to delete file from disk: %s", e)
 
 
     await db.delete(file)
     await db.delete(file)
 
 
@@ -2284,8 +2286,8 @@ async def bulk_delete(
                     abs_file_path.unlink()
                     abs_file_path.unlink()
                 if abs_thumb_path and abs_thumb_path.exists():
                 if abs_thumb_path and abs_thumb_path.exists():
                     abs_thumb_path.unlink()
                     abs_thumb_path.unlink()
-            except Exception as e:
-                logger.warning(f"Failed to delete file from disk: {e}")
+            except OSError as e:
+                logger.warning("Failed to delete file from disk: %s", e)
             await db.delete(file)
             await db.delete(file)
             deleted_files += 1
             deleted_files += 1
 
 
@@ -2348,7 +2350,7 @@ async def get_library_stats(
         disk_free_bytes = disk_stat.free
         disk_free_bytes = disk_stat.free
         disk_total_bytes = disk_stat.total
         disk_total_bytes = disk_stat.total
         disk_used_bytes = disk_stat.used
         disk_used_bytes = disk_stat.used
-    except Exception:
+    except OSError:
         disk_free_bytes = 0
         disk_free_bytes = 0
         disk_total_bytes = 0
         disk_total_bytes = 0
         disk_used_bytes = 0
         disk_used_bytes = 0

+ 1 - 1
backend/app/api/routes/maintenance.py

@@ -654,7 +654,7 @@ async def set_printer_hours(
                 f"{len(items_needing_attention)} items need attention"
                 f"{len(items_needing_attention)} items need attention"
             )
             )
     except Exception as e:
     except Exception as e:
-        logger.warning(f"Failed to send maintenance notification: {e}")
+        logger.warning("Failed to send maintenance notification: %s", e)
 
 
     return {
     return {
         "printer_id": printer_id,
         "printer_id": printer_id,

+ 4 - 4
backend/app/api/routes/notifications.py

@@ -146,7 +146,7 @@ async def create_notification_provider(
     await db.commit()
     await db.commit()
     await db.refresh(provider)
     await db.refresh(provider)
 
 
-    logger.info(f"Created notification provider: {provider.name} ({provider.provider_type})")
+    logger.info("Created notification provider: %s (%s)", provider.name, provider.provider_type)
 
 
     return _provider_to_dict(provider)
     return _provider_to_dict(provider)
 
 
@@ -345,7 +345,7 @@ async def clear_notification_logs(
     await db.commit()
     await db.commit()
 
 
     deleted_count = result.rowcount
     deleted_count = result.rowcount
-    logger.info(f"Deleted {deleted_count} notification logs older than {older_than_days} days")
+    logger.info("Deleted %s notification logs older than %s days", deleted_count, older_than_days)
 
 
     return {"deleted": deleted_count, "message": f"Deleted {deleted_count} logs older than {older_than_days} days"}
     return {"deleted": deleted_count, "message": f"Deleted {deleted_count} logs older than {older_than_days} days"}
 
 
@@ -399,7 +399,7 @@ async def update_notification_provider(
     await db.commit()
     await db.commit()
     await db.refresh(provider)
     await db.refresh(provider)
 
 
-    logger.info(f"Updated notification provider: {provider.name}")
+    logger.info("Updated notification provider: %s", provider.name)
 
 
     return _provider_to_dict(provider)
     return _provider_to_dict(provider)
 
 
@@ -421,7 +421,7 @@ async def delete_notification_provider(
     await db.delete(provider)
     await db.delete(provider)
     await db.commit()
     await db.commit()
 
 
-    logger.info(f"Deleted notification provider: {name}")
+    logger.info("Deleted notification provider: %s", name)
 
 
     return {"message": f"Notification provider '{name}' deleted"}
     return {"message": f"Notification provider '{name}' deleted"}
 
 

+ 5 - 5
backend/app/api/routes/pending_uploads.py

@@ -113,11 +113,11 @@ async def archive_all_pending(
                 # Clean up temp file
                 # Clean up temp file
                 try:
                 try:
                     file_path.unlink()
                     file_path.unlink()
-                except Exception:
+                except OSError:
                     pass
                     pass
             else:
             else:
                 failed += 1
                 failed += 1
-        except Exception:
+        except Exception:  # Mixed async DB + archive operations
             failed += 1
             failed += 1
 
 
     await db.commit()
     await db.commit()
@@ -144,7 +144,7 @@ async def discard_all_pending(
         try:
         try:
             file_path = Path(pending.file_path)
             file_path = Path(pending.file_path)
             file_path.unlink(missing_ok=True)
             file_path.unlink(missing_ok=True)
-        except Exception:
+        except OSError:
             pass
             pass
 
 
         pending.status = "discarded"
         pending.status = "discarded"
@@ -230,7 +230,7 @@ async def archive_pending_upload(
     # Clean up temp file
     # Clean up temp file
     try:
     try:
         file_path.unlink()
         file_path.unlink()
-    except Exception:
+    except OSError:
         pass
         pass
 
 
     return {
     return {
@@ -257,7 +257,7 @@ async def discard_pending_upload(
     file_path = Path(pending.file_path)
     file_path = Path(pending.file_path)
     try:
     try:
         file_path.unlink(missing_ok=True)
         file_path.unlink(missing_ok=True)
-    except Exception:
+    except OSError:
         pass
         pass
 
 
     # Update status
     # Update status

+ 17 - 17
backend/app/api/routes/print_queue.py

@@ -92,8 +92,8 @@ def _extract_filament_types_from_3mf(file_path: Path, plate_id: int | None = Non
                     if used_grams > 0 and filament_type:
                     if used_grams > 0 and filament_type:
                         types.add(filament_type)
                         types.add(filament_type)
 
 
-    except Exception as e:
-        logger.warning(f"Failed to extract filament types from {file_path}: {e}")
+    except (zipfile.BadZipFile, ET.ParseError, OSError, KeyError, ValueError, UnicodeDecodeError) as e:
+        logger.warning("Failed to extract filament types from %s: %s", file_path, e)
 
 
     return sorted(types)
     return sorted(types)
 
 
@@ -144,8 +144,8 @@ def _extract_print_time_from_3mf(file_path: Path, plate_id: int | None = None) -
                                 return int(meta.get("value", "0"))
                                 return int(meta.get("value", "0"))
                             except ValueError:
                             except ValueError:
                                 return None
                                 return None
-    except Exception as e:
-        logger.warning(f"Failed to extract print time from {file_path}: {e}")
+    except (zipfile.BadZipFile, ET.ParseError, OSError, KeyError, ValueError, UnicodeDecodeError) as e:
+        logger.warning("Failed to extract print time from %s: %s", file_path, e)
 
 
     return None
     return None
 
 
@@ -335,7 +335,7 @@ async def add_to_queue(
             filament_types = _extract_filament_types_from_3mf(file_path, data.plate_id)
             filament_types = _extract_filament_types_from_3mf(file_path, data.plate_id)
             if filament_types:
             if filament_types:
                 required_filament_types = json.dumps(filament_types)
                 required_filament_types = json.dumps(filament_types)
-                logger.info(f"Extracted filament types for model-based queue: {filament_types}")
+                logger.info("Extracted filament types for model-based queue: %s", filament_types)
 
 
     # Get next position for this printer (or for unassigned/model-based items)
     # Get next position for this printer (or for unassigned/model-based items)
     if data.printer_id is not None:
     if data.printer_id is not None:
@@ -385,7 +385,7 @@ async def add_to_queue(
 
 
     source_name = f"archive {data.archive_id}" if data.archive_id else f"library file {data.library_file_id}"
     source_name = f"archive {data.archive_id}" if data.archive_id else f"library file {data.library_file_id}"
     target_desc = data.printer_id or (f"model {target_model_norm}" if target_model_norm else "unassigned")
     target_desc = data.printer_id or (f"model {target_model_norm}" if target_model_norm else "unassigned")
-    logger.info(f"Added {source_name} to queue for {target_desc}")
+    logger.info("Added %s to queue for %s", source_name, target_desc)
 
 
     # MQTT relay - publish queue job added
     # MQTT relay - publish queue job added
     try:
     try:
@@ -481,7 +481,7 @@ async def bulk_update_queue_items(
 
 
     await db.commit()
     await db.commit()
 
 
-    logger.info(f"Bulk updated {updated_count} queue items, skipped {skipped_count}")
+    logger.info("Bulk updated %s queue items, skipped %s", updated_count, skipped_count)
     return PrintQueueBulkUpdateResponse(
     return PrintQueueBulkUpdateResponse(
         updated_count=updated_count,
         updated_count=updated_count,
         skipped_count=skipped_count,
         skipped_count=skipped_count,
@@ -581,7 +581,7 @@ async def update_queue_item(
     await db.commit()
     await db.commit()
     await db.refresh(item, ["archive", "printer", "library_file", "created_by"])
     await db.refresh(item, ["archive", "printer", "library_file", "created_by"])
 
 
-    logger.info(f"Updated queue item {item_id}")
+    logger.info("Updated queue item %s", item_id)
     return _enrich_response(item)
     return _enrich_response(item)
 
 
 
 
@@ -615,7 +615,7 @@ async def delete_queue_item(
     await db.delete(item)
     await db.delete(item)
     await db.commit()
     await db.commit()
 
 
-    logger.info(f"Deleted queue item {item_id}")
+    logger.info("Deleted queue item %s", item_id)
     return {"message": "Queue item deleted"}
     return {"message": "Queue item deleted"}
 
 
 
 
@@ -633,7 +633,7 @@ async def reorder_queue(
             item.position = reorder_item.position
             item.position = reorder_item.position
 
 
     await db.commit()
     await db.commit()
-    logger.info(f"Reordered {len(data.items)} queue items")
+    logger.info("Reordered %s queue items", len(data.items))
     return {"message": f"Reordered {len(data.items)} items"}
     return {"message": f"Reordered {len(data.items)} items"}
 
 
 
 
@@ -668,7 +668,7 @@ async def cancel_queue_item(
     item.completed_at = datetime.now()
     item.completed_at = datetime.now()
     await db.commit()
     await db.commit()
 
 
-    logger.info(f"Cancelled queue item {item_id}")
+    logger.info("Cancelled queue item %s", item_id)
     return {"message": "Queue item cancelled"}
     return {"message": "Queue item cancelled"}
 
 
 
 
@@ -702,9 +702,9 @@ async def stop_queue_item(
     try:
     try:
         stop_sent = printer_manager.stop_print(printer_id)
         stop_sent = printer_manager.stop_print(printer_id)
         if not stop_sent:
         if not stop_sent:
-            logger.warning(f"stop_print returned False for printer {printer_id} - printer may not be connected")
+            logger.warning("stop_print returned False for printer %s - printer may not be connected", printer_id)
     except Exception as e:
     except Exception as e:
-        logger.error(f"Error sending stop command for queue item {item_id}: {e}")
+        logger.error("Error sending stop command for queue item %s: %s", item_id, e)
 
 
     # Update queue item status regardless - if printer is off, print is already stopped
     # Update queue item status regardless - if printer is off, print is already stopped
     item.status = "cancelled"
     item.status = "cancelled"
@@ -720,13 +720,13 @@ async def stop_queue_item(
         if plug and plug.enabled:
         if plug and plug.enabled:
             plug_ip = plug.ip_address
             plug_ip = plug.ip_address
 
 
-    logger.info(f"Stopped printing queue item {item_id} (stop command sent: {stop_sent})")
+    logger.info("Stopped printing queue item %s (stop command sent: %s)", item_id, stop_sent)
 
 
     # Schedule background task for cooldown + power off
     # Schedule background task for cooldown + power off
     if plug_ip:
     if plug_ip:
 
 
         async def cooldown_and_poweroff():
         async def cooldown_and_poweroff():
-            logger.info(f"Auto-off: Waiting for printer {printer_id} to cool down before power off...")
+            logger.info("Auto-off: Waiting for printer %s to cool down before power off...", printer_id)
             await printer_manager.wait_for_cooldown(printer_id, target_temp=50.0, timeout=600)
             await printer_manager.wait_for_cooldown(printer_id, target_temp=50.0, timeout=600)
             # Re-fetch plug since we're in a new async context
             # Re-fetch plug since we're in a new async context
             from backend.app.core.database import async_session
             from backend.app.core.database import async_session
@@ -735,7 +735,7 @@ async def stop_queue_item(
                 result = await new_db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
                 result = await new_db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
                 plug = result.scalar_one_or_none()
                 plug = result.scalar_one_or_none()
                 if plug and plug.enabled:
                 if plug and plug.enabled:
-                    logger.info(f"Auto-off: Powering off printer {printer_id}")
+                    logger.info("Auto-off: Powering off printer %s", printer_id)
                     await tasmota_service.turn_off(plug)
                     await tasmota_service.turn_off(plug)
 
 
         asyncio.create_task(cooldown_and_poweroff())
         asyncio.create_task(cooldown_and_poweroff())
@@ -771,5 +771,5 @@ async def start_queue_item(
     await db.commit()
     await db.commit()
     await db.refresh(item, ["archive", "printer", "library_file", "created_by"])
     await db.refresh(item, ["archive", "printer", "library_file", "created_by"])
 
 
-    logger.info(f"Manually started queue item {item_id} (cleared manual_start flag)")
+    logger.info("Manually started queue item %s (cleared manual_start flag)", item_id)
     return _enrich_response(item)
     return _enrich_response(item)

+ 16 - 16
backend/app/api/routes/printers.py

@@ -383,13 +383,13 @@ async def get_printer_status(
     ams_mapping = raw_data.get("ams_mapping", [])
     ams_mapping = raw_data.get("ams_mapping", [])
     # Get per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left
     # Get per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left
     ams_extruder_map = raw_data.get("ams_extruder_map", {})
     ams_extruder_map = raw_data.get("ams_extruder_map", {})
-    logger.debug(f"API returning ams_mapping: {ams_mapping}, ams_extruder_map: {ams_extruder_map}")
+    logger.debug("API returning ams_mapping: %s, ams_extruder_map: %s", ams_mapping, ams_extruder_map)
 
 
     # tray_now from MQTT is already a global tray ID: (ams_id * 4) + slot_id
     # tray_now from MQTT is already a global tray ID: (ams_id * 4) + slot_id
     # Per OpenBambuAPI docs: 254 = external spool, 255 = no filament, otherwise global tray ID
     # Per OpenBambuAPI docs: 254 = external spool, 255 = no filament, otherwise global tray ID
     # No conversion needed - just use the raw value directly
     # No conversion needed - just use the raw value directly
     tray_now = state.tray_now
     tray_now = state.tray_now
-    logger.debug(f"Using tray_now directly as global ID: {tray_now}")
+    logger.debug("Using tray_now directly as global ID: %s", tray_now)
 
 
     # Filter out chamber temp for models that don't have a real sensor
     # Filter out chamber temp for models that don't have a real sensor
     # P1P, P1S, A1, A1Mini report meaningless chamber_temper values
     # P1P, P1S, A1, A1Mini report meaningless chamber_temper values
@@ -573,7 +573,7 @@ async def get_printer_cover(
         match = re.search(r"plate_(\d+)\.gcode", gcode_file)
         match = re.search(r"plate_(\d+)\.gcode", gcode_file)
         if match:
         if match:
             plate_num = int(match.group(1))
             plate_num = int(match.group(1))
-            logger.info(f"Detected plate number {plate_num} from gcode_file: {gcode_file}")
+            logger.info("Detected plate number %s from gcode_file: %s", plate_num, gcode_file)
 
 
     # Normalize view parameter
     # Normalize view parameter
     view_key = view or "default"
     view_key = view or "default"
@@ -643,10 +643,10 @@ async def get_printer_cover(
         except Exception as e:
         except Exception as e:
             last_error = e
             last_error = e
             if attempt < max_retries:
             if attempt < max_retries:
-                logger.warning(f"FTP download attempt {attempt + 1} failed: {e}, retrying...")
+                logger.warning("FTP download attempt %s failed: %s, retrying...", attempt + 1, e)
                 await asyncio.sleep(0.5 * (attempt + 1))  # Brief backoff
                 await asyncio.sleep(0.5 * (attempt + 1))  # Brief backoff
             else:
             else:
-                logger.error(f"FTP download failed after {max_retries + 1} attempts: {e}")
+                logger.error("FTP download failed after %s attempts: %s", max_retries + 1, e)
 
 
     if last_error and not downloaded:
     if last_error and not downloaded:
         raise HTTPException(503, f"FTP download temporarily unavailable: {last_error}")
         raise HTTPException(503, f"FTP download temporarily unavailable: {last_error}")
@@ -662,7 +662,7 @@ async def get_printer_cover(
         raise HTTPException(500, f"Download reported success but file not found: {temp_path}")
         raise HTTPException(500, f"Download reported success but file not found: {temp_path}")
 
 
     file_size = temp_path.stat().st_size
     file_size = temp_path.stat().st_size
-    logger.info(f"Downloaded file size: {file_size} bytes")
+    logger.info("Downloaded file size: %s bytes", file_size)
 
 
     if file_size == 0:
     if file_size == 0:
         temp_path.unlink()
         temp_path.unlink()
@@ -674,8 +674,8 @@ async def get_printer_cover(
             zf = zipfile.ZipFile(temp_path, "r")
             zf = zipfile.ZipFile(temp_path, "r")
         except zipfile.BadZipFile:
         except zipfile.BadZipFile:
             raise HTTPException(500, "Downloaded file is not a valid 3MF/ZIP archive")
             raise HTTPException(500, "Downloaded file is not a valid 3MF/ZIP archive")
-        except Exception as e:
-            logger.error(f"Failed to open 3MF file: {e}", exc_info=True)
+        except OSError as e:
+            logger.error("Failed to open 3MF file: %s", e, exc_info=True)
             raise HTTPException(500, "Failed to open 3MF file. Check server logs for details.")
             raise HTTPException(500, "Failed to open 3MF file. Check server logs for details.")
 
 
         try:
         try:
@@ -1076,7 +1076,7 @@ async def get_printer_file_plates(
                 )
                 )
 
 
     except Exception as e:
     except Exception as e:
-        logger.warning(f"Failed to parse plates from printer file {path}: {e}")
+        logger.warning("Failed to parse plates from printer file %s: %s", path, e)
 
 
     return {
     return {
         "printer_id": printer_id,
         "printer_id": printer_id,
@@ -1114,7 +1114,7 @@ async def get_printer_file_plate_thumbnail(
             if thumb_path in zf.namelist():
             if thumb_path in zf.namelist():
                 image_data = zf.read(thumb_path)
                 image_data = zf.read(thumb_path)
                 return Response(content=image_data, media_type="image/png")
                 return Response(content=image_data, media_type="image/png")
-    except Exception:
+    except (zipfile.BadZipFile, KeyError, OSError):
         pass
         pass
 
 
     raise HTTPException(status_code=404, detail=f"Thumbnail for plate {plate_index} not found")
     raise HTTPException(status_code=404, detail=f"Thumbnail for plate {plate_index} not found")
@@ -1149,7 +1149,7 @@ async def download_printer_files_as_zip(
                     filename = path.split("/")[-1]
                     filename = path.split("/")[-1]
                     zf.writestr(filename, data)
                     zf.writestr(filename, data)
             except Exception as e:
             except Exception as e:
-                logging.warning(f"Failed to add {path} to ZIP: {e}")
+                logging.warning("Failed to add %s to ZIP: %s", path, e)
                 continue
                 continue
 
 
     zip_buffer.seek(0)
     zip_buffer.seek(0)
@@ -1597,7 +1597,7 @@ async def configure_ams_slot(
     import logging
     import logging
 
 
     logger = logging.getLogger(__name__)
     logger = logging.getLogger(__name__)
-    logger.info(f"[configure_ams_slot] printer_id={printer_id}, ams_id={ams_id}, tray_id={tray_id}")
+    logger.info("[configure_ams_slot] printer_id=%s, ams_id=%s, tray_id=%s", printer_id, ams_id, tray_id)
     logger.info(
     logger.info(
         f"[configure_ams_slot] tray_info_idx={tray_info_idx!r}, tray_type={tray_type!r}, tray_sub_brands={tray_sub_brands!r}"
         f"[configure_ams_slot] tray_info_idx={tray_info_idx!r}, tray_type={tray_type!r}, tray_sub_brands={tray_sub_brands!r}"
     )
     )
@@ -1665,7 +1665,7 @@ async def configure_ams_slot(
     # Request fresh status push from printer so frontend gets updated data via WebSocket
     # Request fresh status push from printer so frontend gets updated data via WebSocket
     logger.info("[configure_ams_slot] Requesting status update from printer")
     logger.info("[configure_ams_slot] Requesting status update from printer")
     update_result = client.request_status_update()
     update_result = client.request_status_update()
-    logger.info(f"[configure_ams_slot] Status update request result: {update_result}")
+    logger.info("[configure_ams_slot] Status update request result: %s", update_result)
 
 
     return {
     return {
         "success": True,
         "success": True,
@@ -1758,7 +1758,7 @@ async def debug_simulate_print_complete(
         "timelapse_was_active": False,
         "timelapse_was_active": False,
     }
     }
 
 
-    logger.info(f"Simulating print complete for printer {printer_id}, archive {archive.id}")
+    logger.info("Simulating print complete for printer %s, archive %s", printer_id, archive.id)
 
 
     # Call the actual on_print_complete handler
     # Call the actual on_print_complete handler
     await on_print_complete(printer_id, data)
     await on_print_complete(printer_id, data)
@@ -1932,9 +1932,9 @@ async def get_printable_objects(
                     if objects:
                     if objects:
                         client.state.printable_objects = objects
                         client.state.printable_objects = objects
                         client.state.printable_objects_bbox_all = bbox_all
                         client.state.printable_objects_bbox_all = bbox_all
-                        logger.info(f"Reloaded {len(objects)} objects for printer {printer_id}")
+                        logger.info("Reloaded %s objects for printer %s", len(objects), printer_id)
             except Exception as e:
             except Exception as e:
-                logger.debug(f"Failed to reload objects from printer: {e}")
+                logger.debug("Failed to reload objects from printer: %s", e)
             finally:
             finally:
                 if temp_path.exists():
                 if temp_path.exists():
                     temp_path.unlink()
                     temp_path.unlink()

+ 6 - 6
backend/app/api/routes/projects.py

@@ -829,7 +829,7 @@ async def upload_attachment(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
 ):
     """Upload an attachment to a project."""
     """Upload an attachment to a project."""
-    logger.info(f"=== UPLOAD START: {file.filename} for project {project_id} ===")
+    logger.info("=== UPLOAD START: %s for project %s ===", file.filename, project_id)
 
 
     # Verify project exists
     # Verify project exists
     result = await db.execute(select(Project).where(Project.id == project_id))
     result = await db.execute(select(Project).where(Project.id == project_id))
@@ -859,9 +859,9 @@ async def upload_attachment(
         with open(file_path, "wb") as f:
         with open(file_path, "wb") as f:
             content = await file.read()
             content = await file.read()
             f.write(content)
             f.write(content)
-        logger.info(f"=== FILE SAVED: {file_path}, size: {len(content)} ===")
+        logger.info("=== FILE SAVED: %s, size: %s ===", file_path, len(content))
     except Exception as e:
     except Exception as e:
-        logger.error(f"Failed to save attachment: {e}")
+        logger.error("Failed to save attachment: %s", e)
         raise HTTPException(status_code=500, detail="Failed to save attachment")
         raise HTTPException(status_code=500, detail="Failed to save attachment")
 
 
     # Update project attachments JSON
     # Update project attachments JSON
@@ -878,7 +878,7 @@ async def upload_attachment(
     project.attachments = attachments
     project.attachments = attachments
     db.add(project)  # Explicitly add to session
     db.add(project)  # Explicitly add to session
 
 
-    logger.info(f"=== BEFORE COMMIT: {len(attachments)} attachments ===")
+    logger.info("=== BEFORE COMMIT: %s attachments ===", len(attachments))
 
 
     await db.flush()
     await db.flush()
     await db.commit()
     await db.commit()
@@ -889,7 +889,7 @@ async def upload_attachment(
     result = await db.execute(select(Project).where(Project.id == project_id))
     result = await db.execute(select(Project).where(Project.id == project_id))
     fresh_project = result.scalar_one()
     fresh_project = result.scalar_one()
 
 
-    logger.info(f"=== VERIFIED: {len(fresh_project.attachments or [])} attachments ===")
+    logger.info("=== VERIFIED: %s attachments ===", len(fresh_project.attachments or []))
 
 
     return {
     return {
         "status": "success",
         "status": "success",
@@ -969,7 +969,7 @@ async def delete_attachment(
         try:
         try:
             os.remove(file_path)
             os.remove(file_path)
         except Exception as e:
         except Exception as e:
-            logger.warning(f"Failed to delete attachment file: {e}")
+            logger.warning("Failed to delete attachment file: %s", e)
 
 
     await db.flush()
     await db.flush()
     await db.refresh(project)
     await db.refresh(project)

+ 10 - 10
backend/app/api/routes/settings.py

@@ -294,9 +294,9 @@ async def create_backup(
                     except shutil.Error as e:
                     except shutil.Error as e:
                         # Some files may have restricted permissions (e.g., SSL keys)
                         # Some files may have restricted permissions (e.g., SSL keys)
                         # Log the error but continue with partial backup
                         # Log the error but continue with partial backup
-                        logger.warning(f"Some files in {name} could not be copied: {e}")
+                        logger.warning("Some files in %s could not be copied: %s", name, e)
                     except PermissionError as e:
                     except PermissionError as e:
-                        logger.warning(f"Permission denied copying {name}: {e}")
+                        logger.warning("Permission denied copying %s: %s", name, e)
 
 
             # 4. Create ZIP
             # 4. Create ZIP
             zip_buffer = io.BytesIO()
             zip_buffer = io.BytesIO()
@@ -315,7 +315,7 @@ async def create_backup(
                 headers={"Content-Disposition": f"attachment; filename={filename}"},
                 headers={"Content-Disposition": f"attachment; filename={filename}"},
             )
             )
     except Exception as e:
     except Exception as e:
-        logger.error(f"Backup failed: {e}", exc_info=True)
+        logger.error("Backup failed: %s", e, exc_info=True)
         return JSONResponse(
         return JSONResponse(
             status_code=500,
             status_code=500,
             content={"success": False, "message": "Backup failed. Check server logs for details."},
             content={"success": False, "message": "Backup failed. Check server logs for details."},
@@ -376,7 +376,7 @@ async def restore_backup(
                     # Give it time to fully release file handles
                     # Give it time to fully release file handles
                     await asyncio.sleep(1)
                     await asyncio.sleep(1)
             except Exception as e:
             except Exception as e:
-                logger.warning(f"Failed to stop virtual printer: {e}")
+                logger.warning("Failed to stop virtual printer: %s", e)
 
 
             # 4. Close current database connections
             # 4. Close current database connections
             logger.info("Closing database connections...")
             logger.info("Closing database connections...")
@@ -400,7 +400,7 @@ async def restore_backup(
             for name, dest_dir in dirs_to_restore:
             for name, dest_dir in dirs_to_restore:
                 src_dir = temp_path / name
                 src_dir = temp_path / name
                 if src_dir.exists():
                 if src_dir.exists():
-                    logger.info(f"Restoring {name} directory...")
+                    logger.info("Restoring %s directory...", name)
                     try:
                     try:
                         # Clear destination contents (not the dir itself - may be Docker mount)
                         # Clear destination contents (not the dir itself - may be Docker mount)
                         if dest_dir.exists():
                         if dest_dir.exists():
@@ -411,7 +411,7 @@ async def restore_backup(
                                     else:
                                     else:
                                         item.unlink()
                                         item.unlink()
                                 except OSError as e:
                                 except OSError as e:
-                                    logger.warning(f"Could not delete {item}: {e}")
+                                    logger.warning("Could not delete %s: %s", item, e)
                         else:
                         else:
                             dest_dir.mkdir(parents=True, exist_ok=True)
                             dest_dir.mkdir(parents=True, exist_ok=True)
                         # Copy contents from backup
                         # Copy contents from backup
@@ -422,7 +422,7 @@ async def restore_backup(
                             else:
                             else:
                                 shutil.copy2(item, dest_item)
                                 shutil.copy2(item, dest_item)
                     except OSError as e:
                     except OSError as e:
-                        logger.warning(f"Could not restore {name} directory: {e}")
+                        logger.warning("Could not restore %s directory: %s", name, e)
                         skipped_dirs.append(name)
                         skipped_dirs.append(name)
 
 
             # 7. Note: Virtual printer and database will be reinitialized on restart
             # 7. Note: Virtual printer and database will be reinitialized on restart
@@ -438,7 +438,7 @@ async def restore_backup(
             }
             }
 
 
         except Exception as e:
         except Exception as e:
-            logger.error(f"Restore failed: {e}", exc_info=True)
+            logger.error("Restore failed: %s", e, exc_info=True)
             return JSONResponse(
             return JSONResponse(
                 status_code=500,
                 status_code=500,
                 content={"success": False, "message": "Restore failed. Check server logs for details."},
                 content={"success": False, "message": "Restore failed. Check server logs for details."},
@@ -636,13 +636,13 @@ async def update_virtual_printer_settings(
             remote_interface_ip=new_remote_iface,
             remote_interface_ip=new_remote_iface,
         )
         )
     except ValueError as e:
     except ValueError as e:
-        logger.warning(f"Virtual printer configuration validation error: {e}")
+        logger.warning("Virtual printer configuration validation error: %s", e)
         return JSONResponse(
         return JSONResponse(
             status_code=400,
             status_code=400,
             content={"detail": "Invalid virtual printer configuration. Check the provided values."},
             content={"detail": "Invalid virtual printer configuration. Check the provided values."},
         )
         )
     except Exception as e:
     except Exception as e:
-        logger.error(f"Failed to configure virtual printer: {e}", exc_info=True)
+        logger.error("Failed to configure virtual printer: %s", e, exc_info=True)
         return JSONResponse(
         return JSONResponse(
             status_code=500,
             status_code=500,
             content={"detail": "Failed to configure virtual printer. Check server logs for details."},
             content={"detail": "Failed to configure virtual printer. Check server logs for details."},

+ 12 - 12
backend/app/api/routes/smart_plugs.py

@@ -144,11 +144,11 @@ async def create_smart_plug(
                 state_on_value=plug.mqtt_state_on_value,
                 state_on_value=plug.mqtt_state_on_value,
             )
             )
             topics = [t for t in [power_topic, energy_topic, state_topic] if t]
             topics = [t for t in [power_topic, energy_topic, state_topic] if t]
-            logger.info(f"Created MQTT plug '{plug.name}' subscribed to {', '.join(set(topics))}")
+            logger.info("Created MQTT plug '%s' subscribed to %s", plug.name, ", ".join(set(topics)))
     elif plug.plug_type == "homeassistant":
     elif plug.plug_type == "homeassistant":
-        logger.info(f"Created Home Assistant plug '{plug.name}' ({plug.ha_entity_id})")
+        logger.info("Created Home Assistant plug '%s' (%s)", plug.name, plug.ha_entity_id)
     else:
     else:
-        logger.info(f"Created Tasmota plug '{plug.name}' at {plug.ip_address}")
+        logger.info("Created Tasmota plug '%s' at %s", plug.name, plug.ip_address)
     return plug
     return plug
 
 
 
 
@@ -230,11 +230,11 @@ def get_local_network_range() -> tuple[str, str]:
         from_ip = f"{base}.1"
         from_ip = f"{base}.1"
         to_ip = f"{base}.254"
         to_ip = f"{base}.254"
 
 
-        logger.info(f"Auto-detected network: {from_ip} - {to_ip} (local IP: {local_ip})")
+        logger.info("Auto-detected network: %s - %s (local IP: %s)", from_ip, to_ip, local_ip)
         return from_ip, to_ip
         return from_ip, to_ip
 
 
-    except Exception as e:
-        logger.error(f"Failed to detect local network: {e}")
+    except OSError as e:
+        logger.error("Failed to detect local network: %s", e)
         # Fallback to common home network
         # Fallback to common home network
         return "192.168.1.1", "192.168.1.254"
         return "192.168.1.1", "192.168.1.254"
 
 
@@ -509,7 +509,7 @@ async def update_smart_plug(
                     state_on_value=plug.mqtt_state_on_value,
                     state_on_value=plug.mqtt_state_on_value,
                 )
                 )
 
 
-    logger.info(f"Updated smart plug '{plug.name}'")
+    logger.info("Updated smart plug '%s'", plug.name)
     return plug
     return plug
 
 
 
 
@@ -535,7 +535,7 @@ async def delete_smart_plug(
     await db.delete(plug)
     await db.delete(plug)
     await db.commit()
     await db.commit()
 
 
-    logger.info(f"Deleted smart plug '{plug_name}'")
+    logger.info("Deleted smart plug '%s'", plug_name)
     return {"message": "Smart plug deleted"}
     return {"message": "Smart plug deleted"}
 
 
 
 
@@ -651,17 +651,17 @@ async def trigger_associated_scripts(printer_id: int, plug_state: str, db: Async
         should_trigger = False
         should_trigger = False
         if plug_state == "ON" and plug.auto_on:
         if plug_state == "ON" and plug.auto_on:
             should_trigger = True
             should_trigger = True
-            logger.info(f"Auto-triggering script '{plug.name}' on printer power-on")
+            logger.info("Auto-triggering script '%s' on printer power-on", plug.name)
         elif plug_state == "OFF" and plug.auto_off:
         elif plug_state == "OFF" and plug.auto_off:
             should_trigger = True
             should_trigger = True
-            logger.info(f"Auto-triggering script '{plug.name}' on printer power-off")
+            logger.info("Auto-triggering script '%s' on printer power-off", plug.name)
 
 
         if should_trigger:
         if should_trigger:
             try:
             try:
                 service = await _get_service_for_plug(plug, db)
                 service = await _get_service_for_plug(plug, db)
                 await service.turn_on(plug)  # Scripts are triggered by calling turn_on
                 await service.turn_on(plug)  # Scripts are triggered by calling turn_on
             except Exception as e:
             except Exception as e:
-                logger.error(f"Failed to trigger script '{plug.name}': {e}")
+                logger.error("Failed to trigger script '%s': %s", plug.name, e)
 
 
 
 
 @router.get("/{plug_id}/status", response_model=SmartPlugStatus)
 @router.get("/{plug_id}/status", response_model=SmartPlugStatus)
@@ -780,7 +780,7 @@ async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: A
         else:
         else:
             message = f"Power consumption is {current_power:.1f}W, below threshold of {threshold:.1f}W"
             message = f"Power consumption is {current_power:.1f}W, below threshold of {threshold:.1f}W"
 
 
-        logger.info(f"Power alert triggered for {plug.name}: {message}")
+        logger.info("Power alert triggered for %s: %s", plug.name, message)
 
 
         # Use printer_error event type for power alerts (closest match)
         # Use printer_error event type for power alerts (closest match)
         await notification_service.send_notification(
         await notification_service.send_notification(

+ 13 - 11
backend/app/api/routes/spoolman.py

@@ -130,7 +130,7 @@ async def connect_spoolman(
 
 
         return {"success": True, "message": f"Connected to Spoolman at {url}"}
         return {"success": True, "message": f"Connected to Spoolman at {url}"}
     except Exception as e:
     except Exception as e:
-        logger.error(f"Failed to connect to Spoolman: {e}")
+        logger.error("Failed to connect to Spoolman: %s", e)
         raise HTTPException(status_code=503, detail=str(e))
         raise HTTPException(status_code=503, detail=str(e))
 
 
 
 
@@ -209,7 +209,7 @@ async def sync_printer_ams(
             # Single AMS unit format - wrap in list
             # Single AMS unit format - wrap in list
             ams_units = [{"id": 0, "tray": ams_data.get("tray", [])}]
             ams_units = [{"id": 0, "tray": ams_data.get("tray", [])}]
         else:
         else:
-            logger.info(f"AMS dict keys for debugging: {list(ams_data.keys())}")
+            logger.info("AMS dict keys for debugging: %s", list(ams_data.keys()))
 
 
     if not ams_units:
     if not ams_units:
         raise HTTPException(
         raise HTTPException(
@@ -260,7 +260,9 @@ async def sync_printer_ams(
                 sync_result = await client.sync_ams_tray(tray, printer.name, disable_weight_sync=disable_weight_sync)
                 sync_result = await client.sync_ams_tray(tray, printer.name, disable_weight_sync=disable_weight_sync)
                 if sync_result:
                 if sync_result:
                     synced += 1
                     synced += 1
-                    logger.info(f"Synced {tray.tray_sub_brands} from {printer.name} AMS {ams_id} tray {tray.tray_id}")
+                    logger.info(
+                        "Synced %s from %s AMS %s tray %s", tray.tray_sub_brands, printer.name, ams_id, tray.tray_id
+                    )
                 else:
                 else:
                     # Bambu Lab spool that wasn't synced (not found in Spoolman)
                     # Bambu Lab spool that wasn't synced (not found in Spoolman)
                     errors.append(f"Spool not found in Spoolman: AMS {ams_id}:{tray.tray_id}")
                     errors.append(f"Spool not found in Spoolman: AMS {ams_id}:{tray.tray_id}")
@@ -273,9 +275,9 @@ async def sync_printer_ams(
     try:
     try:
         cleared = await client.clear_location_for_removed_spools(printer.name, current_tray_uuids)
         cleared = await client.clear_location_for_removed_spools(printer.name, current_tray_uuids)
         if cleared > 0:
         if cleared > 0:
-            logger.info(f"Cleared location for {cleared} spools removed from {printer.name}")
+            logger.info("Cleared location for %s spools removed from %s", cleared, printer.name)
     except Exception as e:
     except Exception as e:
-        logger.error(f"Error clearing locations for removed spools: {e}")
+        logger.error("Error clearing locations for removed spools: %s", e)
 
 
     return SyncResult(
     return SyncResult(
         success=len(errors) == 0,
         success=len(errors) == 0,
@@ -344,15 +346,15 @@ async def sync_all_printers(
                 # Single AMS unit format - wrap in list
                 # Single AMS unit format - wrap in list
                 ams_units = [{"id": 0, "tray": ams_data.get("tray", [])}]
                 ams_units = [{"id": 0, "tray": ams_data.get("tray", [])}]
             else:
             else:
-                logger.debug(f"Printer {printer.name} AMS dict keys: {list(ams_data.keys())}")
+                logger.debug("Printer %s AMS dict keys: %s", printer.name, list(ams_data.keys()))
 
 
         if not ams_units:
         if not ams_units:
-            logger.debug(f"Printer {printer.name} has no AMS units to sync (type: {type(ams_data).__name__})")
+            logger.debug("Printer %s has no AMS units to sync (type: %s)", printer.name, type(ams_data).__name__)
             continue
             continue
 
 
         for ams_unit in ams_units:
         for ams_unit in ams_units:
             if not isinstance(ams_unit, dict):
             if not isinstance(ams_unit, dict):
-                logger.debug(f"Skipping non-dict AMS unit: {type(ams_unit)}")
+                logger.debug("Skipping non-dict AMS unit: %s", type(ams_unit))
                 continue
                 continue
 
 
             ams_id = int(ams_unit.get("id", 0))
             ams_id = int(ams_unit.get("id", 0))
@@ -404,9 +406,9 @@ async def sync_all_printers(
         try:
         try:
             cleared = await client.clear_location_for_removed_spools(printer_name, current_tray_uuids)
             cleared = await client.clear_location_for_removed_spools(printer_name, current_tray_uuids)
             if cleared > 0:
             if cleared > 0:
-                logger.info(f"Cleared location for {cleared} spools removed from {printer_name}")
+                logger.info("Cleared location for %s spools removed from %s", cleared, printer_name)
         except Exception as e:
         except Exception as e:
-            logger.error(f"Error clearing locations for {printer_name}: {e}")
+            logger.error("Error clearing locations for %s: %s", printer_name, e)
 
 
     return SyncResult(
     return SyncResult(
         success=len(all_errors) == 0,
         success=len(all_errors) == 0,
@@ -609,7 +611,7 @@ async def link_spool(
     )
     )
 
 
     if result:
     if result:
-        logger.info(f"Linked Spoolman spool {spool_id} to tray_uuid {tray_uuid}")
+        logger.info("Linked Spoolman spool %s to tray_uuid %s", spool_id, tray_uuid)
         return {"success": True, "message": f"Spool {spool_id} linked to AMS tray"}
         return {"success": True, "message": f"Spool {spool_id} linked to AMS tray"}
     else:
     else:
         raise HTTPException(status_code=500, detail="Failed to update spool")
         raise HTTPException(status_code=500, detail="Failed to update spool")

+ 5 - 5
backend/app/api/routes/support.py

@@ -106,7 +106,7 @@ def _apply_log_level(debug: bool):
         logging.getLogger("httpcore").setLevel(logging.WARNING)
         logging.getLogger("httpcore").setLevel(logging.WARNING)
         logging.getLogger("httpx").setLevel(logging.WARNING)
         logging.getLogger("httpx").setLevel(logging.WARNING)
 
 
-    logger.info(f"Log level changed to {'DEBUG' if debug else 'INFO'}")
+    logger.info("Log level changed to %s", "DEBUG" if debug else "INFO")
 
 
 
 
 @router.get("/debug-logging", response_model=DebugLoggingState)
 @router.get("/debug-logging", response_model=DebugLoggingState)
@@ -269,7 +269,7 @@ def _read_log_entries(
                     entries.append(current_entry)
                     entries.append(current_entry)
 
 
     except Exception as e:
     except Exception as e:
-        logger.error(f"Error reading log file: {e}")
+        logger.error("Error reading log file: %s", e)
         return [], 0
         return [], 0
 
 
     # Entries are already in newest-first order
     # Entries are already in newest-first order
@@ -308,7 +308,7 @@ async def clear_logs(
             logger.info("Log file cleared by user")
             logger.info("Log file cleared by user")
             return {"message": "Logs cleared successfully"}
             return {"message": "Logs cleared successfully"}
         except Exception as e:
         except Exception as e:
-            logger.error(f"Error clearing log file: {e}", exc_info=True)
+            logger.error("Error clearing log file: %s", e, exc_info=True)
             raise HTTPException(status_code=500, detail="Failed to clear logs. Check server logs for details.")
             raise HTTPException(status_code=500, detail="Failed to clear logs. Check server logs for details.")
 
 
     return {"message": "Log file does not exist"}
     return {"message": "Log file does not exist"}
@@ -493,7 +493,7 @@ async def generate_support_bundle(
     zip_buffer.seek(0)
     zip_buffer.seek(0)
 
 
     filename = f"bambuddy-support-{timestamp}.zip"
     filename = f"bambuddy-support-{timestamp}.zip"
-    logger.info(f"Generated support bundle: {filename}")
+    logger.info("Generated support bundle: %s", filename)
 
 
     return StreamingResponse(
     return StreamingResponse(
         zip_buffer, media_type="application/zip", headers={"Content-Disposition": f"attachment; filename={filename}"}
         zip_buffer, media_type="application/zip", headers={"Content-Disposition": f"attachment; filename={filename}"}
@@ -514,4 +514,4 @@ async def init_debug_logging():
                 _apply_log_level(True)
                 _apply_log_level(True)
                 logger.info("Debug logging restored from previous session")
                 logger.info("Debug logging restored from previous session")
     except Exception as e:
     except Exception as e:
-        logger.warning(f"Could not restore debug logging state: {e}")
+        logger.warning("Could not restore debug logging state: %s", e)

+ 7 - 7
backend/app/api/routes/updates.py

@@ -236,7 +236,7 @@ async def check_for_updates(
             }
             }
 
 
     except httpx.HTTPError as e:
     except httpx.HTTPError as e:
-        logger.error(f"Failed to check for updates: {e}")
+        logger.error("Failed to check for updates: %s", e)
         _update_status = {
         _update_status = {
             "status": "error",
             "status": "error",
             "progress": 0,
             "progress": 0,
@@ -269,7 +269,7 @@ async def _perform_update():
             }
             }
             return
             return
 
 
-        logger.info(f"Using git at: {git_path}")
+        logger.info("Using git at: %s", git_path)
 
 
         # Git config to avoid safe.directory issues
         # Git config to avoid safe.directory issues
         git_config = ["-c", f"safe.directory={base_dir}"]
         git_config = ["-c", f"safe.directory={base_dir}"]
@@ -318,7 +318,7 @@ async def _perform_update():
 
 
         if process.returncode != 0:
         if process.returncode != 0:
             error_msg = stderr.decode() if stderr else "Git fetch failed"
             error_msg = stderr.decode() if stderr else "Git fetch failed"
-            logger.error(f"Git fetch failed: {error_msg}")
+            logger.error("Git fetch failed: %s", error_msg)
             _update_status = {
             _update_status = {
                 "status": "error",
                 "status": "error",
                 "progress": 0,
                 "progress": 0,
@@ -349,7 +349,7 @@ async def _perform_update():
 
 
         if process.returncode != 0:
         if process.returncode != 0:
             error_msg = stderr.decode() if stderr else "Git reset failed"
             error_msg = stderr.decode() if stderr else "Git reset failed"
-            logger.error(f"Git reset failed: {error_msg}")
+            logger.error("Git reset failed: %s", error_msg)
             _update_status = {
             _update_status = {
                 "status": "error",
                 "status": "error",
                 "progress": 0,
                 "progress": 0,
@@ -381,7 +381,7 @@ async def _perform_update():
         stdout, stderr = await process.communicate()
         stdout, stderr = await process.communicate()
 
 
         if process.returncode != 0:
         if process.returncode != 0:
-            logger.warning(f"pip install warning: {stderr.decode() if stderr else 'unknown'}")
+            logger.warning("pip install warning: %s", stderr.decode() if stderr else "unknown")
 
 
         # Try to build frontend if npm is available (optional - static files are pre-built)
         # Try to build frontend if npm is available (optional - static files are pre-built)
         npm_path = _find_executable("npm")
         npm_path = _find_executable("npm")
@@ -417,7 +417,7 @@ async def _perform_update():
             stdout, stderr = await process.communicate()
             stdout, stderr = await process.communicate()
 
 
             if process.returncode != 0:
             if process.returncode != 0:
-                logger.warning(f"Frontend build warning: {stderr.decode() if stderr else 'unknown'}")
+                logger.warning("Frontend build warning: %s", stderr.decode() if stderr else "unknown")
         else:
         else:
             logger.info("npm not found or frontend dir missing - using pre-built static files")
             logger.info("npm not found or frontend dir missing - using pre-built static files")
 
 
@@ -431,7 +431,7 @@ async def _perform_update():
         logger.info("Update completed successfully")
         logger.info("Update completed successfully")
 
 
     except Exception as e:
     except Exception as e:
-        logger.error(f"Update failed: {e}")
+        logger.error("Update failed: %s", e)
         _update_status = {
         _update_status = {
             "status": "error",
             "status": "error",
             "progress": 0,
             "progress": 0,

+ 3 - 3
backend/app/api/routes/webhook.py

@@ -179,7 +179,7 @@ async def webhook_start_print(
             plate_id=queue_item.plate_id or 1,
             plate_id=queue_item.plate_id or 1,
         )
         )
     except Exception as e:
     except Exception as e:
-        logger.error(f"Failed to start print: {e}")
+        logger.error("Failed to start print: %s", e)
         raise HTTPException(status_code=500, detail=str(e))
         raise HTTPException(status_code=500, detail=str(e))
 
 
     return {"message": "Print started", "queue_item_id": queue_item.id}
     return {"message": "Print started", "queue_item_id": queue_item.id}
@@ -207,7 +207,7 @@ async def webhook_stop_print(
     try:
     try:
         await printer_manager.stop_print(printer_id)
         await printer_manager.stop_print(printer_id)
     except Exception as e:
     except Exception as e:
-        logger.error(f"Failed to stop print: {e}")
+        logger.error("Failed to stop print: %s", e)
         raise HTTPException(status_code=500, detail=str(e))
         raise HTTPException(status_code=500, detail=str(e))
 
 
     return {"message": "Print stopped"}
     return {"message": "Print stopped"}
@@ -235,7 +235,7 @@ async def webhook_cancel_print(
     try:
     try:
         await printer_manager.cancel_print(printer_id)
         await printer_manager.cancel_print(printer_id)
     except Exception as e:
     except Exception as e:
-        logger.error(f"Failed to cancel print: {e}")
+        logger.error("Failed to cancel print: %s", e)
         raise HTTPException(status_code=500, detail=str(e))
         raise HTTPException(status_code=500, detail=str(e))
 
 
     return {"message": "Print cancelled"}
     return {"message": "Print cancelled"}

+ 2 - 2
backend/app/api/routes/websocket.py

@@ -27,7 +27,7 @@ async def websocket_endpoint(websocket: WebSocket):
                     "data": printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)),
                     "data": printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)),
                 }
                 }
             )
             )
-        logger.info(f"Sent initial status for {len(statuses)} printers")
+        logger.info("Sent initial status for %s printers", len(statuses))
 
 
         # Keep connection alive and handle incoming messages
         # Keep connection alive and handle incoming messages
         while True:
         while True:
@@ -55,5 +55,5 @@ async def websocket_endpoint(websocket: WebSocket):
         logger.info("WebSocket client disconnected normally")
         logger.info("WebSocket client disconnected normally")
         await ws_manager.disconnect(websocket)
         await ws_manager.disconnect(websocket)
     except Exception as e:
     except Exception as e:
-        logger.error(f"WebSocket error: {e}", exc_info=True)
+        logger.error("WebSocket error: %s", e, exc_info=True)
         await ws_manager.disconnect(websocket)
         await ws_manager.disconnect(websocket)

+ 3 - 3
backend/app/core/auth.py

@@ -63,7 +63,7 @@ def _get_jwt_secret() -> str:
             if secret and len(secret) >= 32:
             if secret and len(secret) >= 32:
                 logger.info("Using JWT secret from %s", secret_file)
                 logger.info("Using JWT secret from %s", secret_file)
                 return secret
                 return secret
-        except Exception as e:
+        except OSError as e:
             logger.warning("Failed to read JWT secret file: %s", e)
             logger.warning("Failed to read JWT secret file: %s", e)
 
 
     # 3. Generate new random secret
     # 3. Generate new random secret
@@ -79,7 +79,7 @@ def _get_jwt_secret() -> str:
         # Restrict permissions (owner read/write only)
         # Restrict permissions (owner read/write only)
         secret_file.chmod(0o600)
         secret_file.chmod(0o600)
         logger.info("Generated new JWT secret and saved to %s", secret_file)
         logger.info("Generated new JWT secret and saved to %s", secret_file)
-    except Exception as e:
+    except OSError as e:
         logger.warning(
         logger.warning(
             "Could not save JWT secret to file (%s). "
             "Could not save JWT secret to file (%s). "
             "Secret will be regenerated on restart, invalidating existing tokens. "
             "Secret will be regenerated on restart, invalidating existing tokens. "
@@ -177,7 +177,7 @@ async def _validate_api_key(db: AsyncSession, api_key_value: str) -> APIKey | No
                 await db.commit()
                 await db.commit()
                 return api_key
                 return api_key
     except Exception as e:
     except Exception as e:
-        logger.warning(f"API key validation error: {e}")
+        logger.warning("API key validation error: %s", e)
     return None
     return None
 
 
 
 

+ 2 - 2
backend/app/core/config.py

@@ -35,9 +35,9 @@ def _migrate_database() -> Path:
     if old_db.exists() and not new_db.exists():
     if old_db.exists() and not new_db.exists():
         try:
         try:
             old_db.rename(new_db)
             old_db.rename(new_db)
-            logging.info(f"Migrated database: {old_db} -> {new_db}")
+            logging.info("Migrated database: %s -> %s", old_db, new_db)
         except Exception as e:
         except Exception as e:
-            logging.warning(f"Could not migrate database: {e}. Using old location.")
+            logging.warning("Could not migrate database: %s. Using old location.", e)
             return old_db
             return old_db
 
 
     # If old database exists (and new one now exists too), it was migrated
     # If old database exists (and new one now exists too), it was migrated

+ 129 - 126
backend/app/core/database.py

@@ -1,3 +1,4 @@
+from sqlalchemy.exc import OperationalError
 from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
 from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
 from sqlalchemy.orm import DeclarativeBase
 from sqlalchemy.orm import DeclarativeBase
 
 
@@ -98,113 +99,113 @@ async def run_migrations(conn):
     # Migration: Add is_favorite column to print_archives
     # Migration: Add is_favorite column to print_archives
     try:
     try:
         await conn.execute(text("ALTER TABLE print_archives ADD COLUMN is_favorite BOOLEAN DEFAULT 0"))
         await conn.execute(text("ALTER TABLE print_archives ADD COLUMN is_favorite BOOLEAN DEFAULT 0"))
-    except Exception:
+    except OperationalError:
         # Column already exists
         # Column already exists
         pass
         pass
 
 
     # Migration: Add content_hash column to print_archives for duplicate detection
     # Migration: Add content_hash column to print_archives for duplicate detection
     try:
     try:
         await conn.execute(text("ALTER TABLE print_archives ADD COLUMN content_hash VARCHAR(64)"))
         await conn.execute(text("ALTER TABLE print_archives ADD COLUMN content_hash VARCHAR(64)"))
-    except Exception:
+    except OperationalError:
         # Column already exists
         # Column already exists
         pass
         pass
 
 
     # Migration: Add auto_off_executed column to smart_plugs
     # Migration: Add auto_off_executed column to smart_plugs
     try:
     try:
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN auto_off_executed BOOLEAN DEFAULT 0"))
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN auto_off_executed BOOLEAN DEFAULT 0"))
-    except Exception:
+    except OperationalError:
         # Column already exists
         # Column already exists
         pass
         pass
 
 
     # Migration: Add on_print_stopped column to notification_providers
     # Migration: Add on_print_stopped column to notification_providers
     try:
     try:
         await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_print_stopped BOOLEAN DEFAULT 1"))
         await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_print_stopped BOOLEAN DEFAULT 1"))
-    except Exception:
+    except OperationalError:
         # Column already exists
         # Column already exists
         pass
         pass
 
 
     # Migration: Add source_3mf_path column to print_archives
     # Migration: Add source_3mf_path column to print_archives
     try:
     try:
         await conn.execute(text("ALTER TABLE print_archives ADD COLUMN source_3mf_path VARCHAR(500)"))
         await conn.execute(text("ALTER TABLE print_archives ADD COLUMN source_3mf_path VARCHAR(500)"))
-    except Exception:
+    except OperationalError:
         # Column already exists
         # Column already exists
         pass
         pass
 
 
     # Migration: Add f3d_path column to print_archives for Fusion 360 design files
     # Migration: Add f3d_path column to print_archives for Fusion 360 design files
     try:
     try:
         await conn.execute(text("ALTER TABLE print_archives ADD COLUMN f3d_path VARCHAR(500)"))
         await conn.execute(text("ALTER TABLE print_archives ADD COLUMN f3d_path VARCHAR(500)"))
-    except Exception:
+    except OperationalError:
         # Column already exists
         # Column already exists
         pass
         pass
 
 
     # Migration: Add on_maintenance_due column to notification_providers
     # Migration: Add on_maintenance_due column to notification_providers
     try:
     try:
         await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_maintenance_due BOOLEAN DEFAULT 0"))
         await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_maintenance_due BOOLEAN DEFAULT 0"))
-    except Exception:
+    except OperationalError:
         # Column already exists
         # Column already exists
         pass
         pass
 
 
     # Migration: Add location column to printers for grouping
     # Migration: Add location column to printers for grouping
     try:
     try:
         await conn.execute(text("ALTER TABLE printers ADD COLUMN location VARCHAR(100)"))
         await conn.execute(text("ALTER TABLE printers ADD COLUMN location VARCHAR(100)"))
-    except Exception:
+    except OperationalError:
         # Column already exists
         # Column already exists
         pass
         pass
 
 
     # Migration: Add interval_type column to maintenance_types
     # Migration: Add interval_type column to maintenance_types
     try:
     try:
         await conn.execute(text("ALTER TABLE maintenance_types ADD COLUMN interval_type VARCHAR(20) DEFAULT 'hours'"))
         await conn.execute(text("ALTER TABLE maintenance_types ADD COLUMN interval_type VARCHAR(20) DEFAULT 'hours'"))
-    except Exception:
+    except OperationalError:
         # Column already exists
         # Column already exists
         pass
         pass
 
 
     # Migration: Add custom_interval_type column to printer_maintenance
     # Migration: Add custom_interval_type column to printer_maintenance
     try:
     try:
         await conn.execute(text("ALTER TABLE printer_maintenance ADD COLUMN custom_interval_type VARCHAR(20)"))
         await conn.execute(text("ALTER TABLE printer_maintenance ADD COLUMN custom_interval_type VARCHAR(20)"))
-    except Exception:
+    except OperationalError:
         # Column already exists
         # Column already exists
         pass
         pass
 
 
     # Migration: Add power alert columns to smart_plugs
     # Migration: Add power alert columns to smart_plugs
     try:
     try:
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN power_alert_enabled BOOLEAN DEFAULT 0"))
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN power_alert_enabled BOOLEAN DEFAULT 0"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN power_alert_high REAL"))
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN power_alert_high REAL"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN power_alert_low REAL"))
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN power_alert_low REAL"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN power_alert_last_triggered DATETIME"))
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN power_alert_last_triggered DATETIME"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add schedule columns to smart_plugs
     # Migration: Add schedule columns to smart_plugs
     try:
     try:
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN schedule_enabled BOOLEAN DEFAULT 0"))
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN schedule_enabled BOOLEAN DEFAULT 0"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN schedule_on_time VARCHAR(5)"))
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN schedule_on_time VARCHAR(5)"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN schedule_off_time VARCHAR(5)"))
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN schedule_off_time VARCHAR(5)"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add daily digest columns to notification_providers
     # Migration: Add daily digest columns to notification_providers
     try:
     try:
         await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN daily_digest_enabled BOOLEAN DEFAULT 0"))
         await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN daily_digest_enabled BOOLEAN DEFAULT 0"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN daily_digest_time VARCHAR(5)"))
         await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN daily_digest_time VARCHAR(5)"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add project_id column to print_archives
     # Migration: Add project_id column to print_archives
@@ -212,7 +213,7 @@ async def run_migrations(conn):
         await conn.execute(
         await conn.execute(
             text("ALTER TABLE print_archives ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
             text("ALTER TABLE print_archives ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
         )
         )
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add project_id column to print_queue
     # Migration: Add project_id column to print_queue
@@ -220,7 +221,7 @@ async def run_migrations(conn):
         await conn.execute(
         await conn.execute(
             text("ALTER TABLE print_queue ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
             text("ALTER TABLE print_queue ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
         )
         )
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Create FTS5 virtual table for archive full-text search
     # Migration: Create FTS5 virtual table for archive full-text search
@@ -239,7 +240,7 @@ async def run_migrations(conn):
             )
             )
         """)
         """)
         )
         )
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Create triggers to keep FTS index in sync
     # Migration: Create triggers to keep FTS index in sync
@@ -252,7 +253,7 @@ async def run_migrations(conn):
             END
             END
         """)
         """)
         )
         )
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     try:
     try:
@@ -264,7 +265,7 @@ async def run_migrations(conn):
             END
             END
         """)
         """)
         )
         )
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     try:
     try:
@@ -278,29 +279,29 @@ async def run_migrations(conn):
             END
             END
         """)
         """)
         )
         )
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add auto_off_pending columns to smart_plugs (for restart recovery)
     # Migration: Add auto_off_pending columns to smart_plugs (for restart recovery)
     try:
     try:
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN auto_off_pending BOOLEAN DEFAULT 0"))
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN auto_off_pending BOOLEAN DEFAULT 0"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN auto_off_pending_since DATETIME"))
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN auto_off_pending_since DATETIME"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add AMS alarm notification columns to notification_providers
     # Migration: Add AMS alarm notification columns to notification_providers
     try:
     try:
         await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_ams_humidity_high BOOLEAN DEFAULT 0"))
         await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_ams_humidity_high BOOLEAN DEFAULT 0"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(
         await conn.execute(
             text("ALTER TABLE notification_providers ADD COLUMN on_ams_temperature_high BOOLEAN DEFAULT 0")
             text("ALTER TABLE notification_providers ADD COLUMN on_ams_temperature_high BOOLEAN DEFAULT 0")
         )
         )
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add AMS-HT alarm notification columns to notification_providers
     # Migration: Add AMS-HT alarm notification columns to notification_providers
@@ -308,67 +309,67 @@ async def run_migrations(conn):
         await conn.execute(
         await conn.execute(
             text("ALTER TABLE notification_providers ADD COLUMN on_ams_ht_humidity_high BOOLEAN DEFAULT 0")
             text("ALTER TABLE notification_providers ADD COLUMN on_ams_ht_humidity_high BOOLEAN DEFAULT 0")
         )
         )
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(
         await conn.execute(
             text("ALTER TABLE notification_providers ADD COLUMN on_ams_ht_temperature_high BOOLEAN DEFAULT 0")
             text("ALTER TABLE notification_providers ADD COLUMN on_ams_ht_temperature_high BOOLEAN DEFAULT 0")
         )
         )
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add plate not empty notification column to notification_providers
     # Migration: Add plate not empty notification column to notification_providers
     try:
     try:
         await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_plate_not_empty BOOLEAN DEFAULT 1"))
         await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_plate_not_empty BOOLEAN DEFAULT 1"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add notes column to projects (Phase 2)
     # Migration: Add notes column to projects (Phase 2)
     try:
     try:
         await conn.execute(text("ALTER TABLE projects ADD COLUMN notes TEXT"))
         await conn.execute(text("ALTER TABLE projects ADD COLUMN notes TEXT"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add attachments column to projects (Phase 3)
     # Migration: Add attachments column to projects (Phase 3)
     try:
     try:
         await conn.execute(text("ALTER TABLE projects ADD COLUMN attachments JSON"))
         await conn.execute(text("ALTER TABLE projects ADD COLUMN attachments JSON"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add tags column to projects (Phase 4)
     # Migration: Add tags column to projects (Phase 4)
     try:
     try:
         await conn.execute(text("ALTER TABLE projects ADD COLUMN tags TEXT"))
         await conn.execute(text("ALTER TABLE projects ADD COLUMN tags TEXT"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add due_date column to projects (Phase 5)
     # Migration: Add due_date column to projects (Phase 5)
     try:
     try:
         await conn.execute(text("ALTER TABLE projects ADD COLUMN due_date DATETIME"))
         await conn.execute(text("ALTER TABLE projects ADD COLUMN due_date DATETIME"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add priority column to projects (Phase 5)
     # Migration: Add priority column to projects (Phase 5)
     try:
     try:
         await conn.execute(text("ALTER TABLE projects ADD COLUMN priority VARCHAR(20) DEFAULT 'normal'"))
         await conn.execute(text("ALTER TABLE projects ADD COLUMN priority VARCHAR(20) DEFAULT 'normal'"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add budget column to projects (Phase 6)
     # Migration: Add budget column to projects (Phase 6)
     try:
     try:
         await conn.execute(text("ALTER TABLE projects ADD COLUMN budget REAL"))
         await conn.execute(text("ALTER TABLE projects ADD COLUMN budget REAL"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add is_template column to projects (Phase 8)
     # Migration: Add is_template column to projects (Phase 8)
     try:
     try:
         await conn.execute(text("ALTER TABLE projects ADD COLUMN is_template BOOLEAN DEFAULT 0"))
         await conn.execute(text("ALTER TABLE projects ADD COLUMN is_template BOOLEAN DEFAULT 0"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add template_source_id column to projects (Phase 8)
     # Migration: Add template_source_id column to projects (Phase 8)
     try:
     try:
         await conn.execute(text("ALTER TABLE projects ADD COLUMN template_source_id INTEGER"))
         await conn.execute(text("ALTER TABLE projects ADD COLUMN template_source_id INTEGER"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add parent_id column to projects (Phase 10)
     # Migration: Add parent_id column to projects (Phase 10)
@@ -376,77 +377,77 @@ async def run_migrations(conn):
         await conn.execute(
         await conn.execute(
             text("ALTER TABLE projects ADD COLUMN parent_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
             text("ALTER TABLE projects ADD COLUMN parent_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
         )
         )
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Rename quantity_printed to quantity_acquired in project_bom_items
     # Migration: Rename quantity_printed to quantity_acquired in project_bom_items
     try:
     try:
         await conn.execute(text("ALTER TABLE project_bom_items RENAME COLUMN quantity_printed TO quantity_acquired"))
         await conn.execute(text("ALTER TABLE project_bom_items RENAME COLUMN quantity_printed TO quantity_acquired"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add unit_price column to project_bom_items
     # Migration: Add unit_price column to project_bom_items
     try:
     try:
         await conn.execute(text("ALTER TABLE project_bom_items ADD COLUMN unit_price REAL"))
         await conn.execute(text("ALTER TABLE project_bom_items ADD COLUMN unit_price REAL"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add sourcing_url column to project_bom_items
     # Migration: Add sourcing_url column to project_bom_items
     try:
     try:
         await conn.execute(text("ALTER TABLE project_bom_items ADD COLUMN sourcing_url VARCHAR(512)"))
         await conn.execute(text("ALTER TABLE project_bom_items ADD COLUMN sourcing_url VARCHAR(512)"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Rename notes to remarks in project_bom_items
     # Migration: Rename notes to remarks in project_bom_items
     try:
     try:
         await conn.execute(text("ALTER TABLE project_bom_items RENAME COLUMN notes TO remarks"))
         await conn.execute(text("ALTER TABLE project_bom_items RENAME COLUMN notes TO remarks"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add show_in_switchbar column to smart_plugs
     # Migration: Add show_in_switchbar column to smart_plugs
     try:
     try:
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN show_in_switchbar BOOLEAN DEFAULT 0"))
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN show_in_switchbar BOOLEAN DEFAULT 0"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add runtime tracking columns to printers
     # Migration: Add runtime tracking columns to printers
     try:
     try:
         await conn.execute(text("ALTER TABLE printers ADD COLUMN runtime_seconds INTEGER DEFAULT 0"))
         await conn.execute(text("ALTER TABLE printers ADD COLUMN runtime_seconds INTEGER DEFAULT 0"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE printers ADD COLUMN last_runtime_update DATETIME"))
         await conn.execute(text("ALTER TABLE printers ADD COLUMN last_runtime_update DATETIME"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add quantity column to print_archives for tracking item count
     # Migration: Add quantity column to print_archives for tracking item count
     try:
     try:
         await conn.execute(text("ALTER TABLE print_archives ADD COLUMN quantity INTEGER DEFAULT 1"))
         await conn.execute(text("ALTER TABLE print_archives ADD COLUMN quantity INTEGER DEFAULT 1"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add manual_start column to print_queue for staged prints
     # Migration: Add manual_start column to print_queue for staged prints
     try:
     try:
         await conn.execute(text("ALTER TABLE print_queue ADD COLUMN manual_start BOOLEAN DEFAULT 0"))
         await conn.execute(text("ALTER TABLE print_queue ADD COLUMN manual_start BOOLEAN DEFAULT 0"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add wiki_url column to maintenance_types for documentation links
     # Migration: Add wiki_url column to maintenance_types for documentation links
     try:
     try:
         await conn.execute(text("ALTER TABLE maintenance_types ADD COLUMN wiki_url VARCHAR(500)"))
         await conn.execute(text("ALTER TABLE maintenance_types ADD COLUMN wiki_url VARCHAR(500)"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add ams_mapping column to print_queue for storing filament slot assignments
     # Migration: Add ams_mapping column to print_queue for storing filament slot assignments
     try:
     try:
         await conn.execute(text("ALTER TABLE print_queue ADD COLUMN ams_mapping TEXT"))
         await conn.execute(text("ALTER TABLE print_queue ADD COLUMN ams_mapping TEXT"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add target_parts_count column to projects for tracking total parts needed
     # Migration: Add target_parts_count column to projects for tracking total parts needed
     try:
     try:
         await conn.execute(text("ALTER TABLE projects ADD COLUMN target_parts_count INTEGER"))
         await conn.execute(text("ALTER TABLE projects ADD COLUMN target_parts_count INTEGER"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Make printer_id nullable in print_queue for unassigned queue items
     # Migration: Make printer_id nullable in print_queue for unassigned queue items
@@ -490,19 +491,19 @@ async def run_migrations(conn):
             )
             )
             await conn.execute(text("DROP TABLE print_queue"))
             await conn.execute(text("DROP TABLE print_queue"))
             await conn.execute(text("ALTER TABLE print_queue_new RENAME TO print_queue"))
             await conn.execute(text("ALTER TABLE print_queue_new RENAME TO print_queue"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add plug_type column to smart_plugs for HA integration
     # Migration: Add plug_type column to smart_plugs for HA integration
     try:
     try:
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN plug_type VARCHAR(20) DEFAULT 'tasmota'"))
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN plug_type VARCHAR(20) DEFAULT 'tasmota'"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add ha_entity_id column to smart_plugs for HA integration
     # Migration: Add ha_entity_id column to smart_plugs for HA integration
     try:
     try:
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN ha_entity_id VARCHAR(100)"))
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN ha_entity_id VARCHAR(100)"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add project_id column to library_folders for linking folders to projects
     # Migration: Add project_id column to library_folders for linking folders to projects
@@ -510,7 +511,7 @@ async def run_migrations(conn):
         await conn.execute(
         await conn.execute(
             text("ALTER TABLE library_folders ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
             text("ALTER TABLE library_folders ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
         )
         )
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add archive_id column to library_folders for linking folders to archives
     # Migration: Add archive_id column to library_folders for linking folders to archives
@@ -520,7 +521,7 @@ async def run_migrations(conn):
                 "ALTER TABLE library_folders ADD COLUMN archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL"
                 "ALTER TABLE library_folders ADD COLUMN archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL"
             )
             )
         )
         )
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Make ip_address nullable for HA plugs (SQLite requires table recreation)
     # Migration: Make ip_address nullable for HA plugs (SQLite requires table recreation)
@@ -580,39 +581,39 @@ async def run_migrations(conn):
             )
             )
             await conn.execute(text("DROP TABLE smart_plugs"))
             await conn.execute(text("DROP TABLE smart_plugs"))
             await conn.execute(text("ALTER TABLE smart_plugs_new RENAME TO smart_plugs"))
             await conn.execute(text("ALTER TABLE smart_plugs_new RENAME TO smart_plugs"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add plate_id column to print_queue for multi-plate 3MF support
     # Migration: Add plate_id column to print_queue for multi-plate 3MF support
     try:
     try:
         await conn.execute(text("ALTER TABLE print_queue ADD COLUMN plate_id INTEGER"))
         await conn.execute(text("ALTER TABLE print_queue ADD COLUMN plate_id INTEGER"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add print options columns to print_queue
     # Migration: Add print options columns to print_queue
     try:
     try:
         await conn.execute(text("ALTER TABLE print_queue ADD COLUMN bed_levelling BOOLEAN DEFAULT 1"))
         await conn.execute(text("ALTER TABLE print_queue ADD COLUMN bed_levelling BOOLEAN DEFAULT 1"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE print_queue ADD COLUMN flow_cali BOOLEAN DEFAULT 0"))
         await conn.execute(text("ALTER TABLE print_queue ADD COLUMN flow_cali BOOLEAN DEFAULT 0"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE print_queue ADD COLUMN vibration_cali BOOLEAN DEFAULT 1"))
         await conn.execute(text("ALTER TABLE print_queue ADD COLUMN vibration_cali BOOLEAN DEFAULT 1"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE print_queue ADD COLUMN layer_inspect BOOLEAN DEFAULT 0"))
         await conn.execute(text("ALTER TABLE print_queue ADD COLUMN layer_inspect BOOLEAN DEFAULT 0"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE print_queue ADD COLUMN timelapse BOOLEAN DEFAULT 0"))
         await conn.execute(text("ALTER TABLE print_queue ADD COLUMN timelapse BOOLEAN DEFAULT 0"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE print_queue ADD COLUMN use_ams BOOLEAN DEFAULT 1"))
         await conn.execute(text("ALTER TABLE print_queue ADD COLUMN use_ams BOOLEAN DEFAULT 1"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add library_file_id column to print_queue and make archive_id nullable
     # Migration: Add library_file_id column to print_queue and make archive_id nullable
@@ -623,7 +624,7 @@ async def run_migrations(conn):
                 "ALTER TABLE print_queue ADD COLUMN library_file_id INTEGER REFERENCES library_files(id) ON DELETE CASCADE"
                 "ALTER TABLE print_queue ADD COLUMN library_file_id INTEGER REFERENCES library_files(id) ON DELETE CASCADE"
             )
             )
         )
         )
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Check if archive_id needs to be made nullable (requires table recreation in SQLite)
     # Check if archive_id needs to be made nullable (requires table recreation in SQLite)
@@ -674,21 +675,21 @@ async def run_migrations(conn):
             )
             )
             await conn.execute(text("DROP TABLE print_queue"))
             await conn.execute(text("DROP TABLE print_queue"))
             await conn.execute(text("ALTER TABLE print_queue_new2 RENAME TO print_queue"))
             await conn.execute(text("ALTER TABLE print_queue_new2 RENAME TO print_queue"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add HA energy sensor entity columns to smart_plugs
     # Migration: Add HA energy sensor entity columns to smart_plugs
     try:
     try:
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN ha_power_entity VARCHAR(100)"))
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN ha_power_entity VARCHAR(100)"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN ha_energy_today_entity VARCHAR(100)"))
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN ha_energy_today_entity VARCHAR(100)"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN ha_energy_total_entity VARCHAR(100)"))
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN ha_energy_total_entity VARCHAR(100)"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Create users table for authentication
     # Migration: Create users table for authentication
@@ -707,39 +708,39 @@ async def run_migrations(conn):
         """)
         """)
         )
         )
         await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_users_username ON users(username)"))
         await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_users_username ON users(username)"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add external camera columns to printers
     # Migration: Add external camera columns to printers
     try:
     try:
         await conn.execute(text("ALTER TABLE printers ADD COLUMN external_camera_url VARCHAR(500)"))
         await conn.execute(text("ALTER TABLE printers ADD COLUMN external_camera_url VARCHAR(500)"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE printers ADD COLUMN external_camera_type VARCHAR(20)"))
         await conn.execute(text("ALTER TABLE printers ADD COLUMN external_camera_type VARCHAR(20)"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE printers ADD COLUMN external_camera_enabled BOOLEAN DEFAULT 0"))
         await conn.execute(text("ALTER TABLE printers ADD COLUMN external_camera_enabled BOOLEAN DEFAULT 0"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add external_url column to print_archives for user-defined links (Printables, etc.)
     # Migration: Add external_url column to print_archives for user-defined links (Printables, etc.)
     try:
     try:
         await conn.execute(text("ALTER TABLE print_archives ADD COLUMN external_url VARCHAR(500)"))
         await conn.execute(text("ALTER TABLE print_archives ADD COLUMN external_url VARCHAR(500)"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add sliced_for_model column to print_archives for model-based queue assignment
     # Migration: Add sliced_for_model column to print_archives for model-based queue assignment
     try:
     try:
         await conn.execute(text("ALTER TABLE print_archives ADD COLUMN sliced_for_model VARCHAR(50)"))
         await conn.execute(text("ALTER TABLE print_archives ADD COLUMN sliced_for_model VARCHAR(50)"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add is_external column to library_files for external cloud files
     # Migration: Add is_external column to library_files for external cloud files
     try:
     try:
         await conn.execute(text("ALTER TABLE library_files ADD COLUMN is_external BOOLEAN DEFAULT 0"))
         await conn.execute(text("ALTER TABLE library_files ADD COLUMN is_external BOOLEAN DEFAULT 0"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add project_id column to library_files
     # Migration: Add project_id column to library_files
@@ -747,51 +748,51 @@ async def run_migrations(conn):
         await conn.execute(
         await conn.execute(
             text("ALTER TABLE library_files ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
             text("ALTER TABLE library_files ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
         )
         )
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add is_external column to library_folders for external cloud folders
     # Migration: Add is_external column to library_folders for external cloud folders
     try:
     try:
         await conn.execute(text("ALTER TABLE library_folders ADD COLUMN is_external BOOLEAN DEFAULT 0"))
         await conn.execute(text("ALTER TABLE library_folders ADD COLUMN is_external BOOLEAN DEFAULT 0"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add external folder settings columns to library_folders
     # Migration: Add external folder settings columns to library_folders
     try:
     try:
         await conn.execute(text("ALTER TABLE library_folders ADD COLUMN external_readonly BOOLEAN DEFAULT 0"))
         await conn.execute(text("ALTER TABLE library_folders ADD COLUMN external_readonly BOOLEAN DEFAULT 0"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE library_folders ADD COLUMN external_show_hidden BOOLEAN DEFAULT 0"))
         await conn.execute(text("ALTER TABLE library_folders ADD COLUMN external_show_hidden BOOLEAN DEFAULT 0"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE library_folders ADD COLUMN external_path VARCHAR(500)"))
         await conn.execute(text("ALTER TABLE library_folders ADD COLUMN external_path VARCHAR(500)"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add plate_detection_enabled column to printers
     # Migration: Add plate_detection_enabled column to printers
     try:
     try:
         await conn.execute(text("ALTER TABLE printers ADD COLUMN plate_detection_enabled BOOLEAN DEFAULT 0"))
         await conn.execute(text("ALTER TABLE printers ADD COLUMN plate_detection_enabled BOOLEAN DEFAULT 0"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add plate detection ROI columns to printers
     # Migration: Add plate detection ROI columns to printers
     try:
     try:
         await conn.execute(text("ALTER TABLE printers ADD COLUMN plate_detection_roi_x REAL"))
         await conn.execute(text("ALTER TABLE printers ADD COLUMN plate_detection_roi_x REAL"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE printers ADD COLUMN plate_detection_roi_y REAL"))
         await conn.execute(text("ALTER TABLE printers ADD COLUMN plate_detection_roi_y REAL"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE printers ADD COLUMN plate_detection_roi_w REAL"))
         await conn.execute(text("ALTER TABLE printers ADD COLUMN plate_detection_roi_w REAL"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE printers ADD COLUMN plate_detection_roi_h REAL"))
         await conn.execute(text("ALTER TABLE printers ADD COLUMN plate_detection_roi_h REAL"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Remove UNIQUE constraint from smart_plugs.printer_id
     # Migration: Remove UNIQUE constraint from smart_plugs.printer_id
@@ -858,61 +859,61 @@ async def run_migrations(conn):
             # Drop old table and rename new one
             # Drop old table and rename new one
             await conn.execute(text("DROP TABLE smart_plugs"))
             await conn.execute(text("DROP TABLE smart_plugs"))
             await conn.execute(text("ALTER TABLE smart_plugs_temp RENAME TO smart_plugs"))
             await conn.execute(text("ALTER TABLE smart_plugs_temp RENAME TO smart_plugs"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add show_on_printer_card column to smart_plugs
     # Migration: Add show_on_printer_card column to smart_plugs
     try:
     try:
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN show_on_printer_card BOOLEAN DEFAULT 1"))
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN show_on_printer_card BOOLEAN DEFAULT 1"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add MQTT smart plug fields (legacy)
     # Migration: Add MQTT smart plug fields (legacy)
     try:
     try:
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_topic VARCHAR(200)"))
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_topic VARCHAR(200)"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_path VARCHAR(100)"))
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_path VARCHAR(100)"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_path VARCHAR(100)"))
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_path VARCHAR(100)"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_path VARCHAR(100)"))
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_path VARCHAR(100)"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_multiplier REAL DEFAULT 1.0"))
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_multiplier REAL DEFAULT 1.0"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add enhanced MQTT smart plug fields (separate topics and multipliers)
     # Migration: Add enhanced MQTT smart plug fields (separate topics and multipliers)
     try:
     try:
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_topic VARCHAR(200)"))
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_topic VARCHAR(200)"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_multiplier REAL DEFAULT 1.0"))
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_multiplier REAL DEFAULT 1.0"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_topic VARCHAR(200)"))
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_topic VARCHAR(200)"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_multiplier REAL DEFAULT 1.0"))
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_multiplier REAL DEFAULT 1.0"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_topic VARCHAR(200)"))
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_topic VARCHAR(200)"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_on_value VARCHAR(50)"))
         await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_on_value VARCHAR(50)"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Copy existing mqtt_topic to mqtt_power_topic for backward compatibility
     # Migration: Copy existing mqtt_topic to mqtt_power_topic for backward compatibility
@@ -925,7 +926,7 @@ async def run_migrations(conn):
             WHERE mqtt_topic IS NOT NULL AND mqtt_power_topic IS NULL
             WHERE mqtt_topic IS NOT NULL AND mqtt_power_topic IS NULL
         """)
         """)
         )
         )
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Create groups table for permission-based access control
     # Migration: Create groups table for permission-based access control
@@ -944,7 +945,7 @@ async def run_migrations(conn):
         """)
         """)
         )
         )
         await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_groups_name ON groups(name)"))
         await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_groups_name ON groups(name)"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Create user_groups association table
     # Migration: Create user_groups association table
@@ -960,65 +961,65 @@ async def run_migrations(conn):
             )
             )
         """)
         """)
         )
         )
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add model-based queue assignment columns to print_queue
     # Migration: Add model-based queue assignment columns to print_queue
     try:
     try:
         await conn.execute(text("ALTER TABLE print_queue ADD COLUMN target_model VARCHAR(50)"))
         await conn.execute(text("ALTER TABLE print_queue ADD COLUMN target_model VARCHAR(50)"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE print_queue ADD COLUMN required_filament_types TEXT"))
         await conn.execute(text("ALTER TABLE print_queue ADD COLUMN required_filament_types TEXT"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE print_queue ADD COLUMN waiting_reason TEXT"))
         await conn.execute(text("ALTER TABLE print_queue ADD COLUMN waiting_reason TEXT"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add nozzle_count column to printers (for dual-extruder detection)
     # Migration: Add nozzle_count column to printers (for dual-extruder detection)
     try:
     try:
         await conn.execute(text("ALTER TABLE printers ADD COLUMN nozzle_count INTEGER DEFAULT 1"))
         await conn.execute(text("ALTER TABLE printers ADD COLUMN nozzle_count INTEGER DEFAULT 1"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add print_hours_offset column to printers (baseline hours adjustment)
     # Migration: Add print_hours_offset column to printers (baseline hours adjustment)
     try:
     try:
         await conn.execute(text("ALTER TABLE printers ADD COLUMN print_hours_offset REAL DEFAULT 0.0"))
         await conn.execute(text("ALTER TABLE printers ADD COLUMN print_hours_offset REAL DEFAULT 0.0"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add queue notification event columns to notification_providers
     # Migration: Add queue notification event columns to notification_providers
     try:
     try:
         await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_queue_job_added BOOLEAN DEFAULT 0"))
         await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_queue_job_added BOOLEAN DEFAULT 0"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(
         await conn.execute(
             text("ALTER TABLE notification_providers ADD COLUMN on_queue_job_assigned BOOLEAN DEFAULT 0")
             text("ALTER TABLE notification_providers ADD COLUMN on_queue_job_assigned BOOLEAN DEFAULT 0")
         )
         )
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_queue_job_started BOOLEAN DEFAULT 0"))
         await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_queue_job_started BOOLEAN DEFAULT 0"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_queue_job_waiting BOOLEAN DEFAULT 1"))
         await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_queue_job_waiting BOOLEAN DEFAULT 1"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_queue_job_skipped BOOLEAN DEFAULT 1"))
         await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_queue_job_skipped BOOLEAN DEFAULT 1"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_queue_job_failed BOOLEAN DEFAULT 1"))
         await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_queue_job_failed BOOLEAN DEFAULT 1"))
-    except Exception:
+    except OperationalError:
         pass
         pass
     try:
     try:
         await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_queue_completed BOOLEAN DEFAULT 0"))
         await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_queue_completed BOOLEAN DEFAULT 0"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add created_by_id column to print_archives for user tracking (Issue #206)
     # Migration: Add created_by_id column to print_archives for user tracking (Issue #206)
@@ -1026,7 +1027,7 @@ async def run_migrations(conn):
         await conn.execute(
         await conn.execute(
             text("ALTER TABLE print_archives ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL")
             text("ALTER TABLE print_archives ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL")
         )
         )
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add created_by_id column to print_queue for user tracking (Issue #206)
     # Migration: Add created_by_id column to print_queue for user tracking (Issue #206)
@@ -1034,7 +1035,7 @@ async def run_migrations(conn):
         await conn.execute(
         await conn.execute(
             text("ALTER TABLE print_queue ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL")
             text("ALTER TABLE print_queue ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL")
         )
         )
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add created_by_id column to library_files for user tracking (Issue #206)
     # Migration: Add created_by_id column to library_files for user tracking (Issue #206)
@@ -1042,13 +1043,13 @@ async def run_migrations(conn):
         await conn.execute(
         await conn.execute(
             text("ALTER TABLE library_files ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL")
             text("ALTER TABLE library_files ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL")
         )
         )
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Add target_location column to print_queue for location-based filtering (Issue #220)
     # Migration: Add target_location column to print_queue for location-based filtering (Issue #220)
     try:
     try:
         await conn.execute(text("ALTER TABLE print_queue ADD COLUMN target_location VARCHAR(100)"))
         await conn.execute(text("ALTER TABLE print_queue ADD COLUMN target_location VARCHAR(100)"))
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Migration: Convert absolute paths to relative paths in library_files table
     # Migration: Convert absolute paths to relative paths in library_files table
@@ -1078,7 +1079,7 @@ async def run_migrations(conn):
         """),
         """),
             {"base_dir": base_dir_str, "pattern": base_dir_str + "%"},
             {"base_dir": base_dir_str, "pattern": base_dir_str + "%"},
         )
         )
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
     # Create active_print_spoolman table for Spoolman per-filament tracking
     # Create active_print_spoolman table for Spoolman per-filament tracking
@@ -1098,7 +1099,7 @@ async def run_migrations(conn):
             )
             )
         """)
         """)
         )
         )
-    except Exception:
+    except OperationalError:
         pass
         pass
 
 
 
 
@@ -1199,7 +1200,7 @@ async def seed_default_groups():
                 )
                 )
                 session.add(group)
                 session.add(group)
                 groups_created.append(group_name)
                 groups_created.append(group_name)
-                logger.info(f"Created default group: {group_name}")
+                logger.info("Created default group: %s", group_name)
             else:
             else:
                 # Migrate existing group's permissions from old to new format
                 # Migrate existing group's permissions from old to new format
                 group = existing_groups[group_name]
                 group = existing_groups[group_name]
@@ -1218,7 +1219,9 @@ async def seed_default_groups():
                             if new_perm not in new_permissions:
                             if new_perm not in new_permissions:
                                 new_permissions.append(new_perm)
                                 new_permissions.append(new_perm)
                             updated = True
                             updated = True
-                            logger.info(f"Migrated permission '{old_perm}' to '{new_perm}' in group '{group_name}'")
+                            logger.info(
+                                "Migrated permission '%s' to '%s' in group '%s'", old_perm, new_perm, group_name
+                            )
 
 
                     # For Administrators, also ensure they get *_all permissions if they have any new *_own
                     # For Administrators, also ensure they get *_all permissions if they have any new *_own
                     if group_name == "Administrators":
                     if group_name == "Administrators":
@@ -1261,9 +1264,9 @@ async def seed_default_groups():
 
 
                 if user.role == "admin" and admin_group:
                 if user.role == "admin" and admin_group:
                     user.groups.append(admin_group)
                     user.groups.append(admin_group)
-                    logger.info(f"Migrated admin user '{user.username}' to Administrators group")
+                    logger.info("Migrated admin user '%s' to Administrators group", user.username)
                 elif operators_group:
                 elif operators_group:
                     user.groups.append(operators_group)
                     user.groups.append(operators_group)
-                    logger.info(f"Migrated user '{user.username}' to Operators group")
+                    logger.info("Migrated user '%s' to Operators group", user.username)
 
 
             await session.commit()
             await session.commit()

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 147 - 137
backend/app/main.py


+ 15 - 14
backend/app/services/archive.py

@@ -8,6 +8,7 @@ from datetime import datetime
 from pathlib import Path
 from pathlib import Path
 
 
 from defusedxml import ElementTree as ET
 from defusedxml import ElementTree as ET
+from defusedxml.ElementTree import ParseError as XMLParseError
 from sqlalchemy import and_, or_, select
 from sqlalchemy import and_, or_, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
@@ -56,7 +57,7 @@ class ThreeMFParser:
                 self.metadata.pop("_slice_filament_type", None)
                 self.metadata.pop("_slice_filament_type", None)
                 self.metadata.pop("_slice_filament_color", None)
                 self.metadata.pop("_slice_filament_color", None)
                 self.metadata.pop("_plate_index", None)
                 self.metadata.pop("_plate_index", None)
-        except Exception:
+        except (KeyError, ValueError, zipfile.BadZipFile, XMLParseError, UnicodeDecodeError):
             pass
             pass
         return self.metadata
         return self.metadata
 
 
@@ -151,7 +152,7 @@ class ThreeMFParser:
                         self.metadata["_slice_filament_type"] = ", ".join(types)
                         self.metadata["_slice_filament_type"] = ", ".join(types)
                     if colors:
                     if colors:
                         self.metadata["_slice_filament_color"] = ",".join(colors)
                         self.metadata["_slice_filament_color"] = ",".join(colors)
-        except Exception:
+        except (KeyError, ValueError, XMLParseError, UnicodeDecodeError):
             pass
             pass
 
 
     def _parse_project_settings(self, zf: zipfile.ZipFile):
     def _parse_project_settings(self, zf: zipfile.ZipFile):
@@ -165,7 +166,7 @@ class ThreeMFParser:
                     self._extract_print_settings(data)
                     self._extract_print_settings(data)
                 except json.JSONDecodeError:
                 except json.JSONDecodeError:
                     pass
                     pass
-        except Exception:
+        except (KeyError, ValueError, UnicodeDecodeError):
             pass
             pass
 
 
     def _parse_gcode_header(self, zf: zipfile.ZipFile):
     def _parse_gcode_header(self, zf: zipfile.ZipFile):
@@ -197,7 +198,7 @@ class ThreeMFParser:
 
 
                     raw_model = match.group(1).strip()
                     raw_model = match.group(1).strip()
                     self.metadata["sliced_for_model"] = normalize_printer_model(raw_model)
                     self.metadata["sliced_for_model"] = normalize_printer_model(raw_model)
-        except Exception:
+        except (KeyError, ValueError, UnicodeDecodeError):
             pass
             pass
 
 
     def _extract_filament_info(self, data: dict):
     def _extract_filament_info(self, data: dict):
@@ -238,7 +239,7 @@ class ThreeMFParser:
             if non_support_colors:
             if non_support_colors:
                 self.metadata["filament_color"] = ",".join(non_support_colors)
                 self.metadata["filament_color"] = ",".join(non_support_colors)
 
 
-        except Exception:
+        except (KeyError, ValueError, TypeError, IndexError):
             pass
             pass
 
 
     def _extract_print_settings(self, data: dict):
     def _extract_print_settings(self, data: dict):
@@ -285,7 +286,7 @@ class ThreeMFParser:
                 from backend.app.utils.printer_models import normalize_printer_model
                 from backend.app.utils.printer_models import normalize_printer_model
 
 
                 self.metadata["sliced_for_model"] = normalize_printer_model(data["printer_model"])
                 self.metadata["sliced_for_model"] = normalize_printer_model(data["printer_model"])
-        except Exception:
+        except (KeyError, ValueError, TypeError):
             pass
             pass
 
 
     def _extract_settings_from_content(self, content: str):
     def _extract_settings_from_content(self, content: str):
@@ -309,7 +310,7 @@ class ThreeMFParser:
                             value_end = content.find("}", value_start)
                             value_end = content.find("}", value_start)
                         value = content[value_start:value_end].strip().strip('"')
                         value = content[value_start:value_end].strip().strip('"')
                         self.metadata[key] = converter(value)
                         self.metadata[key] = converter(value)
-                except Exception:
+                except (ValueError, TypeError):
                     pass
                     pass
 
 
     def _parse_3dmodel(self, zf: zipfile.ZipFile):
     def _parse_3dmodel(self, zf: zipfile.ZipFile):
@@ -356,7 +357,7 @@ class ThreeMFParser:
             if "Title" in makerworld_fields:
             if "Title" in makerworld_fields:
                 self.metadata["print_name"] = makerworld_fields["Title"]
                 self.metadata["print_name"] = makerworld_fields["Title"]
 
 
-        except Exception:
+        except (KeyError, ValueError, UnicodeDecodeError):
             pass
             pass
 
 
     def _extract_thumbnail(self, zf: zipfile.ZipFile):
     def _extract_thumbnail(self, zf: zipfile.ZipFile):
@@ -482,7 +483,7 @@ def extract_printable_objects_from_3mf(
                     except ValueError:
                     except ValueError:
                         pass
                         pass
 
 
-    except Exception:
+    except (KeyError, ValueError, zipfile.BadZipFile, XMLParseError, UnicodeDecodeError):
         pass
         pass
 
 
     if include_positions:
     if include_positions:
@@ -603,7 +604,7 @@ class ProjectPageParser:
                                 }
                                 }
                             )
                             )
 
 
-        except Exception as e:
+        except (KeyError, ValueError, zipfile.BadZipFile, UnicodeDecodeError) as e:
             result["_error"] = str(e)
             result["_error"] = str(e)
 
 
         return result
         return result
@@ -628,7 +629,7 @@ class ProjectPageParser:
                     }
                     }
                     content_type = content_types.get(ext, "application/octet-stream")
                     content_type = content_types.get(ext, "application/octet-stream")
                     return (data, content_type)
                     return (data, content_type)
-        except Exception:
+        except (KeyError, zipfile.BadZipFile, OSError):
             pass
             pass
         return None
         return None
 
 
@@ -690,7 +691,7 @@ class ProjectPageParser:
             shutil.move(tmp_path, self.file_path)
             shutil.move(tmp_path, self.file_path)
             return True
             return True
 
 
-        except Exception:
+        except (zipfile.BadZipFile, OSError, UnicodeDecodeError, KeyError, ValueError):
             # Clean up temp file if it exists
             # Clean up temp file if it exists
             if "tmp_path" in locals() and tmp_path.exists():
             if "tmp_path" in locals() and tmp_path.exists():
                 tmp_path.unlink()
                 tmp_path.unlink()
@@ -894,7 +895,7 @@ class ArchiveService:
         printable_objects = metadata.get("printable_objects")
         printable_objects = metadata.get("printable_objects")
         if printable_objects and isinstance(printable_objects, dict):
         if printable_objects and isinstance(printable_objects, dict):
             quantity = len(printable_objects)
             quantity = len(printable_objects)
-            logger.debug(f"Auto-detected {quantity} parts from 3MF printable objects")
+            logger.debug("Auto-detected %s parts from 3MF printable objects", quantity)
 
 
         # Create archive record
         # Create archive record
         archive = PrintArchive(
         archive = PrintArchive(
@@ -998,7 +999,7 @@ class ArchiveService:
             archive.cost = round(archive.cost + additional_cost, 2)
             archive.cost = round(archive.cost + additional_cost, 2)
 
 
         await self.db.commit()
         await self.db.commit()
-        logger.info(f"Added reprint cost {additional_cost} to archive {archive_id}, new total: {archive.cost}")
+        logger.info("Added reprint cost %s to archive %s, new total: %s", additional_cost, archive_id, archive.cost)
         return True
         return True
 
 
     async def list_archives(
     async def list_archives(

+ 6 - 6
backend/app/services/bambu_cloud.py

@@ -109,7 +109,7 @@ class BambuCloudService:
             return {"success": False, "needs_verification": False, "message": error_msg}
             return {"success": False, "needs_verification": False, "message": error_msg}
 
 
         except Exception as e:
         except Exception as e:
-            logger.error(f"Login request failed: {e}")
+            logger.error("Login request failed: %s", e)
             raise BambuCloudAuthError(f"Login request failed: {e}")
             raise BambuCloudAuthError(f"Login request failed: {e}")
 
 
     async def verify_code(self, email: str, code: str) -> dict:
     async def verify_code(self, email: str, code: str) -> dict:
@@ -127,7 +127,7 @@ class BambuCloudService:
             )
             )
 
 
             data = response.json()
             data = response.json()
-            logger.debug(f"Email verify response: status={response.status_code}, hasToken={'accessToken' in data}")
+            logger.debug("Email verify response: status=%s, hasToken=%s", response.status_code, "accessToken" in data)
 
 
             if response.status_code == 200 and "accessToken" in data:
             if response.status_code == 200 and "accessToken" in data:
                 self._set_tokens(data)
                 self._set_tokens(data)
@@ -136,7 +136,7 @@ class BambuCloudService:
             return {"success": False, "message": data.get("message", "Verification failed")}
             return {"success": False, "message": data.get("message", "Verification failed")}
 
 
         except Exception as e:
         except Exception as e:
-            logger.error(f"Email verification failed: {e}")
+            logger.error("Email verification failed: %s", e)
             raise BambuCloudAuthError(f"Verification failed: {e}")
             raise BambuCloudAuthError(f"Verification failed: {e}")
 
 
     async def verify_totp(self, tfa_key: str, code: str) -> dict:
     async def verify_totp(self, tfa_key: str, code: str) -> dict:
@@ -178,13 +178,13 @@ class BambuCloudService:
 
 
             # Handle empty response
             # Handle empty response
             if not response.text or not response.text.strip():
             if not response.text or not response.text.strip():
-                logger.warning(f"TOTP verification returned empty response (status {response.status_code})")
+                logger.warning("TOTP verification returned empty response (status %s)", response.status_code)
                 return {"success": False, "message": "Bambu Cloud returned empty response. Please try again."}
                 return {"success": False, "message": "Bambu Cloud returned empty response. Please try again."}
 
 
             try:
             try:
                 data = response.json()
                 data = response.json()
             except Exception as json_err:
             except Exception as json_err:
-                logger.error(f"Failed to parse TOTP response: {json_err}, body: {response.text[:500]}")
+                logger.error("Failed to parse TOTP response: %s, body: %s", json_err, response.text[:500])
                 return {"success": False, "message": "Invalid response from Bambu Cloud"}
                 return {"success": False, "message": "Invalid response from Bambu Cloud"}
 
 
             # Token might be in accessToken, token field, or cookies
             # Token might be in accessToken, token field, or cookies
@@ -215,7 +215,7 @@ class BambuCloudService:
             return {"success": False, "message": error_msg}
             return {"success": False, "message": error_msg}
 
 
         except Exception as e:
         except Exception as e:
-            logger.error(f"TOTP verification failed: {e}")
+            logger.error("TOTP verification failed: %s", e)
             # Return error instead of raising - don't trigger 401/500
             # Return error instead of raising - don't trigger 401/500
             return {"success": False, "message": f"TOTP verification error: {e}"}
             return {"success": False, "message": f"TOTP verification error: {e}"}
 
 

+ 59 - 59
backend/app/services/bambu_ftp.py

@@ -117,7 +117,7 @@ class BambuFTPClient:
     def cache_mode(cls, ip_address: str, mode: str):
     def cache_mode(cls, ip_address: str, mode: str):
         """Cache the working FTP mode for a printer."""
         """Cache the working FTP mode for a printer."""
         cls._mode_cache[ip_address] = mode
         cls._mode_cache[ip_address] = mode
-        logger.info(f"FTP mode cached for {ip_address}: {mode}")
+        logger.info("FTP mode cached for %s: %s", ip_address, mode)
 
 
     def _should_use_prot_c(self) -> bool:
     def _should_use_prot_c(self) -> bool:
         """Determine if we should use prot_c (clear) mode."""
         """Determine if we should use prot_c (clear) mode."""
@@ -154,25 +154,25 @@ class BambuFTPClient:
             self._ftp.set_pasv(True)
             self._ftp.set_pasv(True)
             # Log welcome message for debugging
             # Log welcome message for debugging
             if hasattr(self._ftp, "welcome") and self._ftp.welcome:
             if hasattr(self._ftp, "welcome") and self._ftp.welcome:
-                logger.debug(f"FTP server welcome: {self._ftp.welcome}")
+                logger.debug("FTP server welcome: %s", self._ftp.welcome)
             logger.info(
             logger.info(
                 f"FTP connected successfully to {self.ip_address} (model={self.printer_model}, prot_c={use_prot_c})"
                 f"FTP connected successfully to {self.ip_address} (model={self.printer_model}, prot_c={use_prot_c})"
             )
             )
             return True
             return True
         except ftplib.error_perm as e:
         except ftplib.error_perm as e:
-            logger.warning(f"FTP connection permission error to {self.ip_address}: {e}")
+            logger.warning("FTP connection permission error to %s: %s", self.ip_address, e)
             self._ftp = None
             self._ftp = None
             return False
             return False
         except TimeoutError as e:
         except TimeoutError as e:
-            logger.warning(f"FTP connection timed out to {self.ip_address}: {e}")
+            logger.warning("FTP connection timed out to %s: %s", self.ip_address, e)
             self._ftp = None
             self._ftp = None
             return False
             return False
         except ssl.SSLError as e:
         except ssl.SSLError as e:
-            logger.warning(f"FTP SSL error connecting to {self.ip_address}: {e}")
+            logger.warning("FTP SSL error connecting to %s: %s", self.ip_address, e)
             self._ftp = None
             self._ftp = None
             return False
             return False
-        except Exception as e:
-            logger.warning(f"FTP connection failed to {self.ip_address}: {e} (type: {type(e).__name__})")
+        except (OSError, ftplib.error_reply) as e:
+            logger.warning("FTP connection failed to %s: %s (type: %s)", self.ip_address, e, type(e).__name__)
             self._ftp = None
             self._ftp = None
             return False
             return False
 
 
@@ -181,7 +181,7 @@ class BambuFTPClient:
         if self._ftp:
         if self._ftp:
             try:
             try:
                 self._ftp.quit()
                 self._ftp.quit()
-            except Exception:
+            except (OSError, ftplib.error_reply):
                 pass
                 pass
             self._ftp = None
             self._ftp = None
 
 
@@ -238,9 +238,9 @@ class BambuFTPClient:
                     if mtime:
                     if mtime:
                         file_entry["mtime"] = mtime
                         file_entry["mtime"] = mtime
                     files.append(file_entry)
                     files.append(file_entry)
-            logger.debug(f"Listed {len(files)} files in {path}")
-        except Exception as e:
-            logger.info(f"FTP list_files failed for {path}: {e}")
+            logger.debug("Listed %s files in %s", len(files), path)
+        except (OSError, ftplib.error_reply) as e:
+            logger.info("FTP list_files failed for %s: %s", path, e)
 
 
         return files
         return files
 
 
@@ -253,7 +253,7 @@ class BambuFTPClient:
             buffer = BytesIO()
             buffer = BytesIO()
             self._ftp.retrbinary(f"RETR {remote_path}", buffer.write)
             self._ftp.retrbinary(f"RETR {remote_path}", buffer.write)
             return buffer.getvalue()
             return buffer.getvalue()
-        except Exception:
+        except (OSError, ftplib.error_reply):
             return None
             return None
 
 
     def download_to_file(self, remote_path: str, local_path: Path) -> bool:
     def download_to_file(self, remote_path: str, local_path: Path) -> bool:
@@ -269,16 +269,16 @@ class BambuFTPClient:
                 f.flush()
                 f.flush()
                 os.fsync(f.fileno())
                 os.fsync(f.fileno())
             file_size = local_path.stat().st_size if local_path.exists() else 0
             file_size = local_path.stat().st_size if local_path.exists() else 0
-            logger.info(f"Successfully downloaded {remote_path} to {local_path} ({file_size} bytes)")
+            logger.info("Successfully downloaded %s to %s (%s bytes)", remote_path, local_path, file_size)
             return True
             return True
-        except Exception as e:
+        except (OSError, ftplib.error_reply) as e:
             # Log at INFO level so we can see failures in normal logs
             # Log at INFO level so we can see failures in normal logs
-            logger.info(f"FTP download failed for {remote_path}: {e}")
+            logger.info("FTP download failed for %s: %s", remote_path, e)
             # Clean up partial file if it exists
             # Clean up partial file if it exists
             if local_path.exists():
             if local_path.exists():
                 try:
                 try:
                     local_path.unlink()
                     local_path.unlink()
-                except Exception:
+                except OSError:
                     pass
                     pass
             return False
             return False
 
 
@@ -301,10 +301,10 @@ class BambuFTPClient:
         # Try to get current directory
         # Try to get current directory
         try:
         try:
             results["pwd"] = self._ftp.pwd()
             results["pwd"] = self._ftp.pwd()
-            logger.debug(f"FTP current directory: {results['pwd']}")
-        except Exception as e:
+            logger.debug("FTP current directory: %s", results["pwd"])
+        except (OSError, ftplib.error_reply) as e:
             results["errors"].append(f"PWD failed: {e}")
             results["errors"].append(f"PWD failed: {e}")
-            logger.debug(f"FTP PWD failed: {e}")
+            logger.debug("FTP PWD failed: %s", e)
 
 
         # Try to list root directory
         # Try to list root directory
         try:
         try:
@@ -313,10 +313,10 @@ class BambuFTPClient:
             self._ftp.retrlines("LIST", items.append)
             self._ftp.retrlines("LIST", items.append)
             results["can_list_root"] = True
             results["can_list_root"] = True
             results["root_files"] = items[:10]  # First 10 entries
             results["root_files"] = items[:10]  # First 10 entries
-            logger.debug(f"FTP root listing ({len(items)} items): {items[:5]}")
-        except Exception as e:
+            logger.debug("FTP root listing (%s items): %s", len(items), items[:5])
+        except (OSError, ftplib.error_reply) as e:
             results["errors"].append(f"LIST / failed: {e}")
             results["errors"].append(f"LIST / failed: {e}")
-            logger.debug(f"FTP LIST / failed: {e}")
+            logger.debug("FTP LIST / failed: %s", e)
 
 
         # Try to list /cache (should exist on all printers)
         # Try to list /cache (should exist on all printers)
         try:
         try:
@@ -324,16 +324,16 @@ class BambuFTPClient:
             items = []
             items = []
             self._ftp.retrlines("LIST", items.append)
             self._ftp.retrlines("LIST", items.append)
             results["can_list_cache"] = True
             results["can_list_cache"] = True
-            logger.debug(f"FTP /cache listing: {len(items)} items")
-        except Exception as e:
+            logger.debug("FTP /cache listing: %s items", len(items))
+        except (OSError, ftplib.error_reply) as e:
             results["errors"].append(f"LIST /cache failed: {e}")
             results["errors"].append(f"LIST /cache failed: {e}")
-            logger.debug(f"FTP LIST /cache failed: {e}")
+            logger.debug("FTP LIST /cache failed: %s", e)
 
 
         # Try to get storage info
         # Try to get storage info
         try:
         try:
             results["storage_info"] = self.get_storage_info()
             results["storage_info"] = self.get_storage_info()
-            logger.debug(f"FTP storage info: {results['storage_info']}")
-        except Exception as e:
+            logger.debug("FTP storage info: %s", results["storage_info"])
+        except (OSError, ftplib.error_reply) as e:
             results["errors"].append(f"Storage info failed: {e}")
             results["errors"].append(f"Storage info failed: {e}")
 
 
         return results
         return results
@@ -351,7 +351,7 @@ class BambuFTPClient:
 
 
         try:
         try:
             file_size = local_path.stat().st_size if local_path.exists() else 0
             file_size = local_path.stat().st_size if local_path.exists() else 0
-            logger.info(f"FTP uploading {local_path} ({file_size} bytes) to {remote_path}")
+            logger.info("FTP uploading %s (%s bytes) to %s", local_path, file_size, remote_path)
 
 
             # Run storage diagnostics before upload (debug)
             # Run storage diagnostics before upload (debug)
             logger.debug("Running pre-upload storage diagnostics...")
             logger.debug("Running pre-upload storage diagnostics...")
@@ -362,14 +362,14 @@ class BambuFTPClient:
                 f"storage={diag['storage_info']}, errors={diag['errors']}"
                 f"storage={diag['storage_info']}, errors={diag['errors']}"
             )
             )
             if diag["root_files"]:
             if diag["root_files"]:
-                logger.debug(f"FTP root directory contents: {diag['root_files']}")
+                logger.debug("FTP root directory contents: %s", diag["root_files"])
 
 
             uploaded = 0
             uploaded = 0
 
 
             # Use manual transfer instead of storbinary() for A1 compatibility
             # Use manual transfer instead of storbinary() for A1 compatibility
             # A1 printers have issues with storbinary's voidresp() hanging after transfer
             # A1 printers have issues with storbinary's voidresp() hanging after transfer
             with open(local_path, "rb") as f:
             with open(local_path, "rb") as f:
-                logger.debug(f"FTP STOR command starting for {remote_path}")
+                logger.debug("FTP STOR command starting for %s", remote_path)
                 conn = self._ftp.transfercmd(f"STOR {remote_path}")
                 conn = self._ftp.transfercmd(f"STOR {remote_path}")
 
 
                 # Set explicit socket options for reliable transfer
                 # Set explicit socket options for reliable transfer
@@ -385,24 +385,24 @@ class BambuFTPClient:
 
 
                         conn.sendall(chunk)
                         conn.sendall(chunk)
                         uploaded += len(chunk)
                         uploaded += len(chunk)
-                        logger.debug(f"FTP upload progress: {uploaded}/{file_size} bytes")
+                        logger.debug("FTP upload progress: %s/%s bytes", uploaded, file_size)
 
 
                         if progress_callback:
                         if progress_callback:
                             progress_callback(uploaded, file_size)
                             progress_callback(uploaded, file_size)
 
 
                 except OSError as e:
                 except OSError as e:
-                    logger.error(f"FTP connection lost during upload: {e}")
+                    logger.error("FTP connection lost during upload: %s", e)
                     conn.close()
                     conn.close()
                     raise
                     raise
 
 
                 conn.close()
                 conn.close()
 
 
-            logger.info(f"FTP upload complete: {remote_path}")
+            logger.info("FTP upload complete: %s", remote_path)
             return True
             return True
         except ftplib.error_perm as e:
         except ftplib.error_perm as e:
             # Permanent FTP error (4xx/5xx response)
             # Permanent FTP error (4xx/5xx response)
             error_code = str(e)[:3] if str(e) else "unknown"
             error_code = str(e)[:3] if str(e) else "unknown"
-            logger.error(f"FTP upload failed for {remote_path}: {e} (error code: {error_code})")
+            logger.error("FTP upload failed for %s: %s (error code: %s)", remote_path, e, error_code)
             if error_code == "553":
             if error_code == "553":
                 logger.error(
                 logger.error(
                     "FTP 553 error - Could not create file. Possible causes: "
                     "FTP 553 error - Could not create file. Possible causes: "
@@ -414,8 +414,8 @@ class BambuFTPClient:
             elif error_code == "552":
             elif error_code == "552":
                 logger.error("FTP 552 error - Storage quota exceeded (SD card full?)")
                 logger.error("FTP 552 error - Storage quota exceeded (SD card full?)")
             return False
             return False
-        except Exception as e:
-            logger.error(f"FTP upload failed for {remote_path}: {e} (type: {type(e).__name__})")
+        except (OSError, ftplib.error_reply) as e:
+            logger.error("FTP upload failed for %s: %s (type: %s)", remote_path, e, type(e).__name__)
             return False
             return False
 
 
     def upload_bytes(self, data: bytes, remote_path: str) -> bool:
     def upload_bytes(self, data: bytes, remote_path: str) -> bool:
@@ -437,13 +437,13 @@ class BambuFTPClient:
                     conn.sendall(chunk)
                     conn.sendall(chunk)
                     offset += len(chunk)
                     offset += len(chunk)
             except OSError as e:
             except OSError as e:
-                logger.error(f"FTP connection lost during upload_bytes: {e}")
+                logger.error("FTP connection lost during upload_bytes: %s", e)
                 conn.close()
                 conn.close()
                 raise
                 raise
 
 
             conn.close()
             conn.close()
             return True
             return True
-        except Exception:
+        except (OSError, ftplib.error_reply):
             return False
             return False
 
 
     def delete_file(self, remote_path: str) -> bool:
     def delete_file(self, remote_path: str) -> bool:
@@ -454,8 +454,8 @@ class BambuFTPClient:
         try:
         try:
             self._ftp.delete(remote_path)
             self._ftp.delete(remote_path)
             return True
             return True
-        except Exception as e:
-            logger.warning(f"Failed to delete {remote_path}: {e}")
+        except (OSError, ftplib.error_reply) as e:
+            logger.warning("Failed to delete %s: %s", remote_path, e)
             return False
             return False
 
 
     def get_file_size(self, remote_path: str) -> int | None:
     def get_file_size(self, remote_path: str) -> int | None:
@@ -465,7 +465,7 @@ class BambuFTPClient:
 
 
         try:
         try:
             return self._ftp.size(remote_path)
             return self._ftp.size(remote_path)
-        except Exception:
+        except (OSError, ftplib.error_reply):
             return None
             return None
 
 
     def get_storage_info(self) -> dict | None:
     def get_storage_info(self) -> dict | None:
@@ -478,19 +478,19 @@ class BambuFTPClient:
         # Try AVBL command (available space) - some FTP servers support this
         # Try AVBL command (available space) - some FTP servers support this
         try:
         try:
             response = self._ftp.sendcmd("AVBL")
             response = self._ftp.sendcmd("AVBL")
-            logger.debug(f"AVBL response: {response}")
+            logger.debug("AVBL response: %s", response)
             # Response format: "213 <bytes available>"
             # Response format: "213 <bytes available>"
             if response.startswith("213"):
             if response.startswith("213"):
                 parts = response.split()
                 parts = response.split()
                 if len(parts) >= 2:
                 if len(parts) >= 2:
                     result["free_bytes"] = int(parts[1])
                     result["free_bytes"] = int(parts[1])
-        except Exception as e:
-            logger.debug(f"AVBL command not supported: {e}")
+        except (OSError, ftplib.error_reply) as e:
+            logger.debug("AVBL command not supported: %s", e)
             # Try STAT command as fallback
             # Try STAT command as fallback
             try:
             try:
                 response = self._ftp.sendcmd("STAT")
                 response = self._ftp.sendcmd("STAT")
-                logger.debug(f"STAT response: {response}")
-            except Exception:
+                logger.debug("STAT response: %s", response)
+            except (OSError, ftplib.error_reply):
                 pass
                 pass
 
 
         # Calculate used space by listing root directories
         # Calculate used space by listing root directories
@@ -511,11 +511,11 @@ class BambuFTPClient:
                                 total_used += int(parts[4])
                                 total_used += int(parts[4])
                             except ValueError:
                             except ValueError:
                                 pass
                                 pass
-                except Exception:
+                except (OSError, ftplib.error_reply):
                     pass
                     pass
 
 
             result["used_bytes"] = total_used
             result["used_bytes"] = total_used
-        except Exception:
+        except (OSError, ftplib.error_reply):
             pass
             pass
 
 
         return result if result else None
         return result if result else None
@@ -587,7 +587,7 @@ async def download_file_async(
         return False
         return False
 
 
     except TimeoutError:
     except TimeoutError:
-        logger.warning(f"FTP download timed out after {timeout}s for {remote_path}")
+        logger.warning("FTP download timed out after %ss for %s", timeout, remote_path)
         return False
         return False
 
 
 
 
@@ -658,7 +658,7 @@ async def upload_file_async(
             ip_address, access_code, timeout=socket_timeout, printer_model=printer_model, force_prot_c=force_prot_c
             ip_address, access_code, timeout=socket_timeout, printer_model=printer_model, force_prot_c=force_prot_c
         )
         )
         if client.connect():
         if client.connect():
-            logger.info(f"FTP connected to {ip_address}")
+            logger.info("FTP connected to %s", ip_address)
             try:
             try:
                 result = client.upload_file(local_path, remote_path, progress_callback)
                 result = client.upload_file(local_path, remote_path, progress_callback)
                 if result:
                 if result:
@@ -667,7 +667,7 @@ async def upload_file_async(
                 return result
                 return result
             finally:
             finally:
                 client.disconnect()
                 client.disconnect()
-        logger.warning(f"FTP connection failed to {ip_address}")
+        logger.warning("FTP connection failed to %s", ip_address)
         return False
         return False
 
 
     try:
     try:
@@ -694,7 +694,7 @@ async def upload_file_async(
         return False
         return False
 
 
     except TimeoutError:
     except TimeoutError:
-        logger.warning(f"FTP upload timed out after {timeout}s for {remote_path}")
+        logger.warning("FTP upload timed out after %ss for %s", timeout, remote_path)
         return False
         return False
 
 
 
 
@@ -726,7 +726,7 @@ async def list_files_async(
     try:
     try:
         return await asyncio.wait_for(loop.run_in_executor(None, _list), timeout=timeout)
         return await asyncio.wait_for(loop.run_in_executor(None, _list), timeout=timeout)
     except TimeoutError:
     except TimeoutError:
-        logger.warning(f"FTP list_files timed out after {timeout}s for {path}")
+        logger.warning("FTP list_files timed out after %ss for %s", timeout, path)
         return []
         return []
 
 
 
 
@@ -856,21 +856,21 @@ async def with_ftp_retry(
             # Check for "falsy" success indicators
             # Check for "falsy" success indicators
             if result not in (False, None, []):
             if result not in (False, None, []):
                 if attempt > 0:
                 if attempt > 0:
-                    logger.info(f"{operation_name} succeeded on attempt {attempt + 1}/{max_retries + 1}")
+                    logger.info("%s succeeded on attempt %s/%s", operation_name, attempt + 1, max_retries + 1)
                 return result
                 return result
             # Operation returned failure indicator
             # Operation returned failure indicator
             if attempt > 0:
             if attempt > 0:
-                logger.info(f"{operation_name} attempt {attempt + 1}/{max_retries + 1} returned failure")
+                logger.info("%s attempt %s/%s returned failure", operation_name, attempt + 1, max_retries + 1)
         except Exception as e:
         except Exception as e:
             last_error = e
             last_error = e
-            logger.warning(f"{operation_name} attempt {attempt + 1}/{max_retries + 1} failed: {e}")
+            logger.warning("%s attempt %s/%s failed: %s", operation_name, attempt + 1, max_retries + 1, e)
 
 
         # Don't wait after the last attempt
         # Don't wait after the last attempt
         if attempt < max_retries:
         if attempt < max_retries:
-            logger.info(f"{operation_name} will retry in {retry_delay}s...")
+            logger.info("%s will retry in %ss...", operation_name, retry_delay)
             await asyncio.sleep(retry_delay)
             await asyncio.sleep(retry_delay)
 
 
-    logger.error(f"{operation_name} failed after {max_retries + 1} attempts")
+    logger.error("%s failed after %s attempts", operation_name, max_retries + 1)
     if last_error:
     if last_error:
-        logger.debug(f"Last error: {last_error}")
+        logger.debug("Last error: %s", last_error)
     return None
     return None

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 130 - 124
backend/app/services/bambu_mqtt.py


+ 22 - 22
backend/app/services/camera.py

@@ -54,7 +54,7 @@ def get_ffmpeg_path() -> str | None:
 
 
     _ffmpeg_path = ffmpeg_path
     _ffmpeg_path = ffmpeg_path
     if ffmpeg_path:
     if ffmpeg_path:
-        logger.info(f"Found ffmpeg at: {ffmpeg_path}")
+        logger.info("Found ffmpeg at: %s", ffmpeg_path)
     else:
     else:
         logger.warning("ffmpeg not found in PATH or common locations")
         logger.warning("ffmpeg not found in PATH or common locations")
 
 
@@ -193,7 +193,7 @@ async def read_chamber_image_frame(
             payload_size = struct.unpack("<I", header[0:4])[0]
             payload_size = struct.unpack("<I", header[0:4])[0]
 
 
             if payload_size == 0 or payload_size > 10_000_000:  # Sanity check: max 10MB
             if payload_size == 0 or payload_size > 10_000_000:  # Sanity check: max 10MB
-                logger.error(f"Chamber image: invalid payload size {payload_size}")
+                logger.error("Chamber image: invalid payload size %s", payload_size)
                 return None
                 return None
 
 
             # Read the JPEG data
             # Read the JPEG data
@@ -210,24 +210,24 @@ async def read_chamber_image_frame(
             if not jpeg_data.endswith(JPEG_END):
             if not jpeg_data.endswith(JPEG_END):
                 logger.warning("Chamber image: JPEG missing end marker, may be truncated")
                 logger.warning("Chamber image: JPEG missing end marker, may be truncated")
 
 
-            logger.debug(f"Chamber image: received {len(jpeg_data)} bytes")
+            logger.debug("Chamber image: received %s bytes", len(jpeg_data))
             return jpeg_data
             return jpeg_data
 
 
         finally:
         finally:
             writer.close()
             writer.close()
             try:
             try:
                 await writer.wait_closed()
                 await writer.wait_closed()
-            except Exception:
+            except OSError:
                 pass
                 pass
 
 
     except TimeoutError:
     except TimeoutError:
-        logger.error(f"Chamber image: connection timeout to {ip_address}:{port}")
+        logger.error("Chamber image: connection timeout to %s:%s", ip_address, port)
         return None
         return None
     except ConnectionRefusedError:
     except ConnectionRefusedError:
-        logger.error(f"Chamber image: connection refused by {ip_address}:{port}")
+        logger.error("Chamber image: connection refused by %s:%s", ip_address, port)
         return None
         return None
     except Exception as e:
     except Exception as e:
-        logger.exception(f"Chamber image: error connecting to {ip_address}:{port}: {e}")
+        logger.exception("Chamber image: error connecting to %s:%s: %s", ip_address, port, e)
         return None
         return None
 
 
 
 
@@ -254,11 +254,11 @@ async def generate_chamber_image_stream(
         writer.write(auth_payload)
         writer.write(auth_payload)
         await writer.drain()
         await writer.drain()
 
 
-        logger.info(f"Chamber image: connected to {ip_address}:{port}")
+        logger.info("Chamber image: connected to %s:%s", ip_address, port)
         return reader, writer
         return reader, writer
 
 
     except Exception as e:
     except Exception as e:
-        logger.error(f"Chamber image: failed to connect to {ip_address}:{port}: {e}")
+        logger.error("Chamber image: failed to connect to %s:%s: %s", ip_address, port, e)
         return None
         return None
 
 
 
 
@@ -272,7 +272,7 @@ async def read_next_chamber_frame(reader: asyncio.StreamReader, timeout: float =
         payload_size = struct.unpack("<I", header[0:4])[0]
         payload_size = struct.unpack("<I", header[0:4])[0]
 
 
         if payload_size == 0 or payload_size > 10_000_000:
         if payload_size == 0 or payload_size > 10_000_000:
-            logger.error(f"Chamber image: invalid payload size {payload_size}")
+            logger.error("Chamber image: invalid payload size %s", payload_size)
             return None
             return None
 
 
         # Read the JPEG data
         # Read the JPEG data
@@ -290,7 +290,7 @@ async def read_next_chamber_frame(reader: asyncio.StreamReader, timeout: float =
         logger.warning("Chamber image: read timeout")
         logger.warning("Chamber image: read timeout")
         return None
         return None
     except Exception as e:
     except Exception as e:
-        logger.error(f"Chamber image: error reading frame: {e}")
+        logger.error("Chamber image: error reading frame: %s", e)
         return None
         return None
 
 
 
 
@@ -323,10 +323,10 @@ async def capture_camera_frame(
         try:
         try:
             with open(output_path, "wb") as f:
             with open(output_path, "wb") as f:
                 f.write(jpeg_data)
                 f.write(jpeg_data)
-            logger.info(f"Saved camera frame to: {output_path}")
+            logger.info("Saved camera frame to: %s", output_path)
             return True
             return True
-        except Exception as e:
-            logger.error(f"Failed to write camera frame: {e}")
+        except OSError as e:
+            logger.error("Failed to write camera frame: %s", e)
             return False
             return False
     return False
     return False
 
 
@@ -353,7 +353,7 @@ async def capture_camera_frame_bytes(
     """
     """
     # Chamber image models: A1/P1 - returns bytes directly
     # Chamber image models: A1/P1 - returns bytes directly
     if is_chamber_image_model(model):
     if is_chamber_image_model(model):
-        logger.info(f"Capturing camera frame bytes from {ip_address} using chamber image protocol (model: {model})")
+        logger.info("Capturing camera frame bytes from %s using chamber image protocol (model: %s)", ip_address, model)
         return await read_chamber_image_frame(ip_address, access_code, timeout=float(timeout))
         return await read_chamber_image_frame(ip_address, access_code, timeout=float(timeout))
 
 
     # RTSP models: X1/H2/P2 - use ffmpeg piping to stdout
     # RTSP models: X1/H2/P2 - use ffmpeg piping to stdout
@@ -384,7 +384,7 @@ async def capture_camera_frame_bytes(
         "-",
         "-",
     ]
     ]
 
 
-    logger.info(f"Capturing camera frame bytes from {ip_address} using RTSP (model: {model})")
+    logger.info("Capturing camera frame bytes from %s using RTSP (model: %s)", ip_address, model)
 
 
     try:
     try:
         process = await asyncio.create_subprocess_exec(
         process = await asyncio.create_subprocess_exec(
@@ -398,22 +398,22 @@ async def capture_camera_frame_bytes(
         except TimeoutError:
         except TimeoutError:
             process.kill()
             process.kill()
             await process.wait()
             await process.wait()
-            logger.error(f"Camera frame bytes capture timed out after {timeout}s")
+            logger.error("Camera frame bytes capture timed out after %ss", timeout)
             return None
             return None
 
 
         if process.returncode == 0 and stdout and len(stdout) >= 100:
         if process.returncode == 0 and stdout and len(stdout) >= 100:
-            logger.info(f"Successfully captured camera frame bytes: {len(stdout)} bytes")
+            logger.info("Successfully captured camera frame bytes: %s bytes", len(stdout))
             return stdout
             return stdout
         else:
         else:
             stderr_text = stderr.decode() if stderr else "Unknown error"
             stderr_text = stderr.decode() if stderr else "Unknown error"
-            logger.error(f"ffmpeg frame bytes capture failed (code {process.returncode}): {stderr_text[:200]}")
+            logger.error("ffmpeg frame bytes capture failed (code %s): %s", process.returncode, stderr_text[:200])
             return None
             return None
 
 
     except FileNotFoundError:
     except FileNotFoundError:
         logger.error("ffmpeg not found for camera frame capture")
         logger.error("ffmpeg not found for camera frame capture")
         return None
         return None
     except Exception as e:
     except Exception as e:
-        logger.exception(f"Camera frame bytes capture failed: {e}")
+        logger.exception("Camera frame bytes capture failed: %s", e)
         return None
         return None
 
 
 
 
@@ -454,10 +454,10 @@ async def capture_finish_photo(
     )
     )
 
 
     if success:
     if success:
-        logger.info(f"Finish photo saved: {filename}")
+        logger.info("Finish photo saved: %s", filename)
         return filename
         return filename
     else:
     else:
-        logger.warning(f"Failed to capture finish photo for printer {printer_id}")
+        logger.warning("Failed to capture finish photo for printer %s", printer_id)
         return None
         return None
 
 
 
 

+ 43 - 43
backend/app/services/discovery.py

@@ -155,13 +155,13 @@ class PrinterDiscoveryService:
             # Enable broadcast
             # Enable broadcast
             sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
             sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
 
 
-            logger.info(f"Starting SSDP discovery on port {SSDP_PORT} for Bambu Lab printers...")
+            logger.info("Starting SSDP discovery on port %s for Bambu Lab printers...", SSDP_PORT)
 
 
             # Send initial M-SEARCH request to trigger responses
             # Send initial M-SEARCH request to trigger responses
             try:
             try:
                 sock.sendto(SSDP_MSEARCH.encode(), (SSDP_ADDR, SSDP_PORT))
                 sock.sendto(SSDP_MSEARCH.encode(), (SSDP_ADDR, SSDP_PORT))
-            except Exception as e:
-                logger.debug(f"M-SEARCH send error: {e}")
+            except OSError as e:
+                logger.debug("M-SEARCH send error: %s", e)
 
 
             start_time = asyncio.get_event_loop().time()
             start_time = asyncio.get_event_loop().time()
             last_send = start_time
             last_send = start_time
@@ -171,13 +171,13 @@ class PrinterDiscoveryService:
                 try:
                 try:
                     data, addr = sock.recvfrom(4096)
                     data, addr = sock.recvfrom(4096)
                     message = data.decode("utf-8", errors="ignore")
                     message = data.decode("utf-8", errors="ignore")
-                    logger.debug(f"Received from {addr[0]}: {message[:100]}...")
+                    logger.debug("Received from %s: %s...", addr[0], message[:100])
                     self._handle_response(message, addr[0])
                     self._handle_response(message, addr[0])
                 except BlockingIOError:
                 except BlockingIOError:
                     # No data available, that's fine
                     # No data available, that's fine
                     pass
                     pass
-                except Exception as e:
-                    logger.debug(f"SSDP receive error: {e}")
+                except OSError as e:
+                    logger.debug("SSDP receive error: %s", e)
 
 
                 # Re-send M-SEARCH every 3 seconds
                 # Re-send M-SEARCH every 3 seconds
                 now = asyncio.get_event_loop().time()
                 now = asyncio.get_event_loop().time()
@@ -185,27 +185,27 @@ class PrinterDiscoveryService:
                     try:
                     try:
                         sock.sendto(SSDP_MSEARCH.encode(), (SSDP_ADDR, SSDP_PORT))
                         sock.sendto(SSDP_MSEARCH.encode(), (SSDP_ADDR, SSDP_PORT))
                         last_send = now
                         last_send = now
-                    except Exception as e:
-                        logger.debug(f"SSDP send error: {e}")
+                    except OSError as e:
+                        logger.debug("SSDP send error: %s", e)
 
 
                 await asyncio.sleep(0.1)
                 await asyncio.sleep(0.1)
 
 
-            logger.info(f"Discovery complete. Found {len(self._discovered)} printers.")
+            logger.info("Discovery complete. Found %s printers.", len(self._discovered))
 
 
         except OSError as e:
         except OSError as e:
             if e.errno == 98:  # Address already in use
             if e.errno == 98:  # Address already in use
-                logger.warning(f"Port {SSDP_PORT} is in use, trying alternative discovery...")
+                logger.warning("Port %s is in use, trying alternative discovery...", SSDP_PORT)
                 await self._discover_alternative(duration)
                 await self._discover_alternative(duration)
             else:
             else:
-                logger.error(f"Discovery error: {e}")
+                logger.error("Discovery error: %s", e)
         except Exception as e:
         except Exception as e:
-            logger.error(f"Discovery error: {e}")
+            logger.error("Discovery error: %s", e)
         finally:
         finally:
             self._running = False
             self._running = False
             if sock:
             if sock:
                 try:
                 try:
                     sock.close()
                     sock.close()
-                except Exception:
+                except OSError:
                     pass
                     pass
 
 
     async def _discover_alternative(self, duration: float):
     async def _discover_alternative(self, duration: float):
@@ -233,48 +233,48 @@ class PrinterDiscoveryService:
                     self._handle_response(data.decode("utf-8", errors="ignore"), addr[0])
                     self._handle_response(data.decode("utf-8", errors="ignore"), addr[0])
                 except BlockingIOError:
                 except BlockingIOError:
                     pass
                     pass
-                except Exception as e:
-                    logger.debug(f"SSDP receive error: {e}")
+                except OSError as e:
+                    logger.debug("SSDP receive error: %s", e)
 
 
                 now = asyncio.get_event_loop().time()
                 now = asyncio.get_event_loop().time()
                 if now - last_send >= 2.0:
                 if now - last_send >= 2.0:
                     try:
                     try:
                         sock.sendto(SSDP_MSEARCH.encode(), (SSDP_ADDR, SSDP_PORT))
                         sock.sendto(SSDP_MSEARCH.encode(), (SSDP_ADDR, SSDP_PORT))
                         last_send = now
                         last_send = now
-                    except Exception:
+                    except OSError:
                         pass
                         pass
 
 
                 await asyncio.sleep(0.1)
                 await asyncio.sleep(0.1)
 
 
-            logger.info(f"Alternative discovery complete. Found {len(self._discovered)} printers.")
+            logger.info("Alternative discovery complete. Found %s printers.", len(self._discovered))
         except Exception as e:
         except Exception as e:
-            logger.error(f"Alternative discovery error: {e}")
+            logger.error("Alternative discovery error: %s", e)
         finally:
         finally:
             if sock:
             if sock:
                 try:
                 try:
                     sock.close()
                     sock.close()
-                except Exception:
+                except OSError:
                     pass
                     pass
 
 
     def _handle_response(self, response: str, ip_address: str):
     def _handle_response(self, response: str, ip_address: str):
         """Parse SSDP response and extract printer info."""
         """Parse SSDP response and extract printer info."""
         # Check if it's a Bambu Lab printer response
         # Check if it's a Bambu Lab printer response
         if BAMBU_SEARCH_TARGET not in response and "bambulab" not in response.lower():
         if BAMBU_SEARCH_TARGET not in response and "bambulab" not in response.lower():
-            logger.debug(f"Ignoring non-Bambu response from {ip_address}")
+            logger.debug("Ignoring non-Bambu response from %s", ip_address)
             return
             return
 
 
         # Extract USN (Unique Service Name) which contains the serial
         # Extract USN (Unique Service Name) which contains the serial
         # Bambu format is just "USN: SERIALNUMBER" (no uuid: prefix)
         # Bambu format is just "USN: SERIALNUMBER" (no uuid: prefix)
         usn_match = re.search(r"USN:\s*(?:uuid:)?([^\s\r\n]+)", response, re.IGNORECASE)
         usn_match = re.search(r"USN:\s*(?:uuid:)?([^\s\r\n]+)", response, re.IGNORECASE)
         if not usn_match:
         if not usn_match:
-            logger.debug(f"No USN found in response from {ip_address}")
+            logger.debug("No USN found in response from %s", ip_address)
             return
             return
 
 
         serial = usn_match.group(1).strip()
         serial = usn_match.group(1).strip()
 
 
         # Skip Bambuddy's own virtual printer (any model variant)
         # Skip Bambuddy's own virtual printer (any model variant)
         if serial.endswith(VIRTUAL_PRINTER_SERIAL_SUFFIX):
         if serial.endswith(VIRTUAL_PRINTER_SERIAL_SUFFIX):
-            logger.debug(f"Ignoring Bambuddy virtual printer at {ip_address}")
+            logger.debug("Ignoring Bambuddy virtual printer at %s", ip_address)
             return
             return
 
 
         # Extract device name from LOCATION or DevName header
         # Extract device name from LOCATION or DevName header
@@ -308,7 +308,7 @@ class PrinterDiscoveryService:
         )
         )
 
 
         self._discovered[serial] = printer
         self._discovered[serial] = printer
-        logger.info(f"Discovered printer: {name} ({serial}) at {ip_address}")
+        logger.info("Discovered printer: %s (%s) at %s", name, serial, ip_address)
 
 
 
 
 class SubnetScanner:
 class SubnetScanner:
@@ -360,11 +360,11 @@ class SubnetScanner:
             self._total = len(hosts)
             self._total = len(hosts)
 
 
             if self._total > 1024:
             if self._total > 1024:
-                logger.warning(f"Subnet {subnet} has {self._total} hosts, limiting to /22 (1024 hosts)")
+                logger.warning("Subnet %s has %s hosts, limiting to /22 (1024 hosts)", subnet, self._total)
                 self._total = 1024
                 self._total = 1024
                 hosts = hosts[:1024]
                 hosts = hosts[:1024]
 
 
-            logger.info(f"Starting subnet scan of {subnet} ({self._total} hosts)")
+            logger.info("Starting subnet scan of %s (%s hosts)", subnet, self._total)
 
 
             # Scan in batches to avoid overwhelming the network
             # Scan in batches to avoid overwhelming the network
             batch_size = 50
             batch_size = 50
@@ -377,11 +377,11 @@ class SubnetScanner:
                 await asyncio.gather(*tasks, return_exceptions=True)
                 await asyncio.gather(*tasks, return_exceptions=True)
                 self._scanned = min(i + batch_size, len(hosts))
                 self._scanned = min(i + batch_size, len(hosts))
 
 
-            logger.info(f"Subnet scan complete. Found {len(self._discovered)} printers.")
+            logger.info("Subnet scan complete. Found %s printers.", len(self._discovered))
             return self.discovered_printers
             return self.discovered_printers
 
 
         except ValueError as e:
         except ValueError as e:
-            logger.error(f"Invalid subnet format: {e}")
+            logger.error("Invalid subnet format: %s", e)
             return []
             return []
         finally:
         finally:
             self._running = False
             self._running = False
@@ -399,14 +399,14 @@ class SubnetScanner:
             return
             return
 
 
         # Both ports open - likely a Bambu printer
         # Both ports open - likely a Bambu printer
-        logger.info(f"Found potential Bambu printer at {ip}")
+        logger.info("Found potential Bambu printer at %s", ip)
 
 
         # Try to get printer info via SSDP unicast
         # Try to get printer info via SSDP unicast
         serial, name, model = await self._get_printer_info_ssdp(ip, timeout)
         serial, name, model = await self._get_printer_info_ssdp(ip, timeout)
 
 
         # Skip Bambuddy's own virtual printer (any model variant)
         # Skip Bambuddy's own virtual printer (any model variant)
         if serial and serial.endswith(VIRTUAL_PRINTER_SERIAL_SUFFIX):
         if serial and serial.endswith(VIRTUAL_PRINTER_SERIAL_SUFFIX):
-            logger.debug(f"Ignoring Bambuddy virtual printer at {ip}")
+            logger.debug("Ignoring Bambuddy virtual printer at %s", ip)
             return
             return
 
 
         printer = DiscoveredPrinter(
         printer = DiscoveredPrinter(
@@ -461,11 +461,11 @@ class SubnetScanner:
                 if model_match:
                 if model_match:
                     model = model_match.group(1).strip()
                     model = model_match.group(1).strip()
 
 
-                logger.debug(f"SSDP info from {ip}: serial={serial}, name={name}, model={model}")
+                logger.debug("SSDP info from %s: serial=%s, name=%s, model=%s", ip, serial, name, model)
                 return serial, name, model
                 return serial, name, model
 
 
-            except Exception as e:
-                logger.debug(f"SSDP query to {ip} failed: {e}")
+            except OSError as e:
+                logger.debug("SSDP query to %s failed: %s", ip, e)
                 return None, None, None
                 return None, None, None
 
 
         return await loop.run_in_executor(None, _query)
         return await loop.run_in_executor(None, _query)
@@ -476,7 +476,7 @@ class SubnetScanner:
             _, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=timeout)
             _, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=timeout)
             writer.close()
             writer.close()
             await writer.wait_closed()
             await writer.wait_closed()
-            logger.debug(f"Port {port} open on {ip}")
+            logger.debug("Port %s open on %s", port, ip)
             return True
             return True
         except TimeoutError:
         except TimeoutError:
             return False
             return False
@@ -485,7 +485,7 @@ class SubnetScanner:
         except OSError as e:
         except OSError as e:
             # Log first few errors to help debug network issues
             # Log first few errors to help debug network issues
             if self._scanned < 5:
             if self._scanned < 5:
-                logger.debug(f"OSError checking {ip}:{port}: {e}")
+                logger.debug("OSError checking %s:%s: %s", ip, port, e)
             return False
             return False
 
 
     def stop(self):
     def stop(self):
@@ -549,11 +549,11 @@ class TasmotaScanner:
             self._total = len(hosts)
             self._total = len(hosts)
 
 
             if self._total > 1024:
             if self._total > 1024:
-                logger.warning(f"IP range has {self._total} hosts, limiting to 1024")
+                logger.warning("IP range has %s hosts, limiting to 1024", self._total)
                 self._total = 1024
                 self._total = 1024
                 hosts = hosts[:1024]
                 hosts = hosts[:1024]
 
 
-            logger.info(f"Starting Tasmota scan from {from_ip} to {to_ip} ({self._total} hosts)")
+            logger.info("Starting Tasmota scan from %s to %s (%s hosts)", from_ip, to_ip, self._total)
 
 
             # Scan in batches to avoid overwhelming the network
             # Scan in batches to avoid overwhelming the network
             batch_size = 50
             batch_size = 50
@@ -567,14 +567,14 @@ class TasmotaScanner:
                 try:
                 try:
                     await asyncio.gather(*tasks, return_exceptions=True)
                     await asyncio.gather(*tasks, return_exceptions=True)
                 except Exception as e:
                 except Exception as e:
-                    logger.warning(f"Batch {i // batch_size} error: {e}")
+                    logger.warning("Batch %s error: %s", i // batch_size, e)
                 self._scanned = min(i + batch_size, len(hosts))
                 self._scanned = min(i + batch_size, len(hosts))
 
 
-            logger.info(f"Tasmota scan complete. Found {len(self._discovered)} devices.")
+            logger.info("Tasmota scan complete. Found %s devices.", len(self._discovered))
             return self.discovered_devices
             return self.discovered_devices
 
 
         except ValueError as e:
         except ValueError as e:
-            logger.error(f"Invalid IP address format: {e}")
+            logger.error("Invalid IP address format: %s", e)
             return []
             return []
         finally:
         finally:
             self._running = False
             self._running = False
@@ -603,7 +603,7 @@ class TasmotaScanner:
                     power_response = await client.get(power_url)
                     power_response = await client.get(power_url)
                     if power_response.status_code == 401:
                     if power_response.status_code == 401:
                         # Device requires auth - still a Tasmota device!
                         # Device requires auth - still a Tasmota device!
-                        logger.info(f"Discovered Tasmota at {ip} (requires auth - 401)")
+                        logger.info("Discovered Tasmota at %s (requires auth - 401)", ip)
                         device = {
                         device = {
                             "ip_address": ip,
                             "ip_address": ip,
                             "name": f"Tasmota ({ip})",
                             "name": f"Tasmota ({ip})",
@@ -621,7 +621,7 @@ class TasmotaScanner:
 
 
                     # Check for Tasmota auth warning (returns 200 with WARNING)
                     # Check for Tasmota auth warning (returns 200 with WARNING)
                     if "WARNING" in power_data:
                     if "WARNING" in power_data:
-                        logger.info(f"Discovered Tasmota at {ip} (requires auth)")
+                        logger.info("Discovered Tasmota at %s (requires auth)", ip)
                         device = {
                         device = {
                             "ip_address": ip,
                             "ip_address": ip,
                             "name": f"Tasmota ({ip})",
                             "name": f"Tasmota ({ip})",
@@ -638,7 +638,7 @@ class TasmotaScanner:
                         return
                         return
 
 
                 except Exception as e:
                 except Exception as e:
-                    logger.debug(f"Error probing {ip}: {e}")
+                    logger.debug("Error probing %s: %s", ip, e)
                     return
                     return
 
 
                 # It's a Tasmota device! Now get more info
                 # It's a Tasmota device! Now get more info
@@ -672,7 +672,7 @@ class TasmotaScanner:
                 }
                 }
 
 
                 self._discovered[ip] = device
                 self._discovered[ip] = device
-                logger.info(f"Discovered Tasmota device: {device_name} at {ip}")
+                logger.info("Discovered Tasmota device: %s at %s", device_name, ip)
 
 
         except httpx.TimeoutException:
         except httpx.TimeoutException:
             pass
             pass

+ 47 - 47
backend/app/services/external_camera.py

@@ -62,12 +62,12 @@ def _sanitize_camera_url(url: str, allowed_schemes: tuple[str, ...] = ("http", "
             "0.0.0.0",
             "0.0.0.0",
         )
         )
         if hostname_lower in blocked_hosts:
         if hostname_lower in blocked_hosts:
-            logger.warning(f"Blocked camera URL targeting restricted host: {hostname}")
+            logger.warning("Blocked camera URL targeting restricted host: %s", hostname)
             return None
             return None
 
 
         # Block link-local addresses (169.254.x.x)
         # Block link-local addresses (169.254.x.x)
         if hostname.startswith("169.254."):
         if hostname.startswith("169.254."):
-            logger.warning(f"Blocked camera URL targeting link-local address: {hostname}")
+            logger.warning("Blocked camera URL targeting link-local address: %s", hostname)
             return None
             return None
 
 
         # Reconstruct URL from validated components to break taint chain
         # Reconstruct URL from validated components to break taint chain
@@ -80,7 +80,7 @@ def _sanitize_camera_url(url: str, allowed_schemes: tuple[str, ...] = ("http", "
         # Build sanitized URL from validated components
         # Build sanitized URL from validated components
         sanitized = f"{scheme}://{hostname}{port_str}{path}{query}{fragment}"
         sanitized = f"{scheme}://{hostname}{port_str}{path}{query}{fragment}"
         return sanitized
         return sanitized
-    except Exception:
+    except ValueError:
         return None
         return None
 
 
 
 
@@ -144,7 +144,7 @@ def list_usb_cameras() -> list[dict]:
                         info["formats"] = list(set(formats))
                         info["formats"] = list(set(formats))
 
 
             except (subprocess.TimeoutExpired, Exception) as e:
             except (subprocess.TimeoutExpired, Exception) as e:
-                logger.debug(f"v4l2-ctl failed for {device_path}: {e}")
+                logger.debug("v4l2-ctl failed for %s: %s", device_path, e)
 
 
         # Only include devices that look like video capture devices
         # Only include devices that look like video capture devices
         # Skip metadata devices (typically odd numbered like video1, video3)
         # Skip metadata devices (typically odd numbered like video1, video3)
@@ -184,7 +184,7 @@ async def capture_frame(url: str, camera_type: str, timeout: int = 15) -> bytes
     Returns:
     Returns:
         JPEG bytes or None on failure
         JPEG bytes or None on failure
     """
     """
-    logger.debug(f"capture_frame called: type={camera_type}, url={url[:50] if url else 'None'}...")
+    logger.debug("capture_frame called: type=%s, url=%s...", camera_type, url[:50] if url else "None")
     if camera_type == "mjpeg":
     if camera_type == "mjpeg":
         return await _capture_mjpeg_frame(url, timeout)
         return await _capture_mjpeg_frame(url, timeout)
     elif camera_type == "rtsp":
     elif camera_type == "rtsp":
@@ -194,7 +194,7 @@ async def capture_frame(url: str, camera_type: str, timeout: int = 15) -> bytes
     elif camera_type == "usb":
     elif camera_type == "usb":
         return await _capture_usb_frame(url, timeout)
         return await _capture_usb_frame(url, timeout)
     else:
     else:
-        logger.warning(f"Unknown camera type: {camera_type}")
+        logger.warning("Unknown camera type: %s", camera_type)
         return None
         return None
 
 
 
 
@@ -211,21 +211,21 @@ async def _capture_usb_frame(device: str, timeout: int) -> bytes | None:
 
 
     device_match = regex_module.match(r"^/dev/video(\d{1,2})$", device)
     device_match = regex_module.match(r"^/dev/video(\d{1,2})$", device)
     if not device_match:
     if not device_match:
-        logger.error(f"Invalid USB device path format: {device}")
+        logger.error("Invalid USB device path format: %s", device)
         return None
         return None
 
 
     # Convert to integer to break taint chain - integers cannot contain path traversal
     # Convert to integer to break taint chain - integers cannot contain path traversal
     # lgtm[py/path-injection] - device_num is validated integer 0-99
     # lgtm[py/path-injection] - device_num is validated integer 0-99
     device_num = int(device_match.group(1))  # Safe: regex guarantees 1-2 digits
     device_num = int(device_match.group(1))  # Safe: regex guarantees 1-2 digits
     if device_num > 99:
     if device_num > 99:
-        logger.error(f"USB device number out of range: {device_num}")
+        logger.error("USB device number out of range: %s", device_num)
         return None
         return None
 
 
     # Construct safe path from validated integer (completely untainted)
     # Construct safe path from validated integer (completely untainted)
     safe_device_path = Path(f"/dev/video{device_num}")  # lgtm[py/path-injection]
     safe_device_path = Path(f"/dev/video{device_num}")  # lgtm[py/path-injection]
 
 
     if not safe_device_path.exists():
     if not safe_device_path.exists():
-        logger.error(f"USB device does not exist: {safe_device_path}")
+        logger.error("USB device does not exist: %s", safe_device_path)
         return None
         return None
 
 
     # Use the safe path for ffmpeg - this is a hardcoded /dev/videoN path
     # Use the safe path for ffmpeg - this is a hardcoded /dev/videoN path
@@ -250,7 +250,7 @@ async def _capture_usb_frame(device: str, timeout: int) -> bytes | None:
     ]
     ]
 
 
     try:
     try:
-        logger.debug(f"Running USB capture: {' '.join(cmd)}")
+        logger.debug("Running USB capture: %s", " ".join(cmd))
         process = await asyncio.create_subprocess_exec(
         process = await asyncio.create_subprocess_exec(
             *cmd,
             *cmd,
             stdout=asyncio.subprocess.PIPE,
             stdout=asyncio.subprocess.PIPE,
@@ -260,7 +260,7 @@ async def _capture_usb_frame(device: str, timeout: int) -> bytes | None:
         stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
         stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
 
 
         if process.returncode != 0:
         if process.returncode != 0:
-            logger.error(f"ffmpeg USB capture failed: {stderr.decode()[:200]}")
+            logger.error("ffmpeg USB capture failed: %s", stderr.decode()[:200])
             return None
             return None
 
 
         if not stdout or len(stdout) < 100:
         if not stdout or len(stdout) < 100:
@@ -270,12 +270,12 @@ async def _capture_usb_frame(device: str, timeout: int) -> bytes | None:
         return stdout
         return stdout
 
 
     except TimeoutError:
     except TimeoutError:
-        logger.warning(f"USB frame capture timed out after {timeout}s")
+        logger.warning("USB frame capture timed out after %ss", timeout)
         if process:
         if process:
             process.kill()
             process.kill()
         return None
         return None
-    except Exception as e:
-        logger.error(f"USB frame capture failed: {e}")
+    except OSError as e:
+        logger.error("USB frame capture failed: %s", e)
         return None
         return None
 
 
 
 
@@ -289,7 +289,7 @@ async def _capture_mjpeg_frame(url: str, timeout: int) -> bytes | None:
     # Sanitize URL - returns reconstructed URL from validated components
     # Sanitize URL - returns reconstructed URL from validated components
     safe_url = _sanitize_camera_url(url, ("http", "https"))
     safe_url = _sanitize_camera_url(url, ("http", "https"))
     if not safe_url:
     if not safe_url:
-        logger.error(f"Invalid MJPEG URL format: {url[:50]}...")
+        logger.error("Invalid MJPEG URL format: %s...", url[:50])
         return None
         return None
 
 
     try:
     try:
@@ -298,7 +298,7 @@ async def _capture_mjpeg_frame(url: str, timeout: int) -> bytes | None:
             session.get(safe_url) as response,
             session.get(safe_url) as response,
         ):
         ):
             if response.status != 200:
             if response.status != 200:
-                logger.error(f"MJPEG stream returned status {response.status}")
+                logger.error("MJPEG stream returned status %s", response.status)
                 return None
                 return None
 
 
             # Read chunks until we find a complete JPEG frame
             # Read chunks until we find a complete JPEG frame
@@ -326,10 +326,10 @@ async def _capture_mjpeg_frame(url: str, timeout: int) -> bytes | None:
                     return None
                     return None
 
 
     except TimeoutError:
     except TimeoutError:
-        logger.warning(f"MJPEG frame capture timed out after {timeout}s")
+        logger.warning("MJPEG frame capture timed out after %ss", timeout)
         return None
         return None
-    except Exception as e:
-        logger.error(f"MJPEG frame capture failed: {e}")
+    except (aiohttp.ClientError, OSError) as e:
+        logger.error("MJPEG frame capture failed: %s", e)
         return None
         return None
 
 
     return None
     return None
@@ -375,7 +375,7 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
         )
         )
 
 
         if process.returncode != 0:
         if process.returncode != 0:
-            logger.error(f"ffmpeg RTSP capture failed: {stderr.decode()[:200]}")
+            logger.error("ffmpeg RTSP capture failed: %s", stderr.decode()[:200])
             print(f"[EXT-CAM] ffmpeg error: {stderr.decode()[:300]}")
             print(f"[EXT-CAM] ffmpeg error: {stderr.decode()[:300]}")
             return None
             return None
 
 
@@ -386,12 +386,12 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
         return stdout
         return stdout
 
 
     except TimeoutError:
     except TimeoutError:
-        logger.warning(f"RTSP frame capture timed out after {timeout}s")
+        logger.warning("RTSP frame capture timed out after %ss", timeout)
         if process:
         if process:
             process.kill()
             process.kill()
         return None
         return None
-    except Exception as e:
-        logger.error(f"RTSP frame capture failed: {e}")
+    except OSError as e:
+        logger.error("RTSP frame capture failed: %s", e)
         return None
         return None
 
 
 
 
@@ -405,7 +405,7 @@ async def _capture_snapshot(url: str, timeout: int) -> bytes | None:
     # Sanitize URL - returns reconstructed URL from validated components
     # Sanitize URL - returns reconstructed URL from validated components
     safe_url = _sanitize_camera_url(url, ("http", "https"))
     safe_url = _sanitize_camera_url(url, ("http", "https"))
     if not safe_url:
     if not safe_url:
-        logger.error(f"Invalid snapshot URL format: {url[:50]}...")
+        logger.error("Invalid snapshot URL format: %s...", url[:50])
         return None
         return None
 
 
     try:
     try:
@@ -414,7 +414,7 @@ async def _capture_snapshot(url: str, timeout: int) -> bytes | None:
             session.get(safe_url) as response,
             session.get(safe_url) as response,
         ):
         ):
             if response.status != 200:
             if response.status != 200:
-                logger.error(f"Snapshot URL returned status {response.status}")
+                logger.error("Snapshot URL returned status %s", response.status)
                 return None
                 return None
 
 
             data = await response.read()
             data = await response.read()
@@ -427,10 +427,10 @@ async def _capture_snapshot(url: str, timeout: int) -> bytes | None:
             return data
             return data
 
 
     except TimeoutError:
     except TimeoutError:
-        logger.warning(f"Snapshot capture timed out after {timeout}s")
+        logger.warning("Snapshot capture timed out after %ss", timeout)
         return None
         return None
-    except Exception as e:
-        logger.error(f"Snapshot capture failed: {e}")
+    except (aiohttp.ClientError, OSError) as e:
+        logger.error("Snapshot capture failed: %s", e)
         return None
         return None
 
 
 
 
@@ -441,11 +441,11 @@ async def test_connection(url: str, camera_type: str) -> dict:
         Dict with {success: bool, error?: str, resolution?: str}
         Dict with {success: bool, error?: str, resolution?: str}
     """
     """
     print(f"[EXT-CAM] Testing camera connection: type={camera_type}, url={url[:50]}...")
     print(f"[EXT-CAM] Testing camera connection: type={camera_type}, url={url[:50]}...")
-    logger.info(f"Testing camera connection: type={camera_type}, url={url[:50]}...")
+    logger.info("Testing camera connection: type=%s, url=%s...", camera_type, url[:50])
     try:
     try:
         frame = await capture_frame(url, camera_type, timeout=10)
         frame = await capture_frame(url, camera_type, timeout=10)
         print(f"[EXT-CAM] Capture result: {len(frame) if frame else 0} bytes")
         print(f"[EXT-CAM] Capture result: {len(frame) if frame else 0} bytes")
-        logger.info(f"Capture result: {len(frame) if frame else 0} bytes")
+        logger.info("Capture result: %s bytes", len(frame) if frame else 0)
 
 
         if frame:
         if frame:
             # Try to get resolution from JPEG header
             # Try to get resolution from JPEG header
@@ -461,7 +461,7 @@ async def test_connection(url: str, camera_type: str) -> dict:
                         width = (frame[idx + 7] << 8) | frame[idx + 8]
                         width = (frame[idx + 7] << 8) | frame[idx + 8]
                         resolution = f"{width}x{height}"
                         resolution = f"{width}x{height}"
                         break
                         break
-            except Exception:
+            except (IndexError, ValueError):
                 pass
                 pass
 
 
             return {"success": True, "resolution": resolution}
             return {"success": True, "resolution": resolution}
@@ -471,7 +471,7 @@ async def test_connection(url: str, camera_type: str) -> dict:
     except Exception as e:
     except Exception as e:
         # Sanitize error message - don't expose internal details
         # Sanitize error message - don't expose internal details
         error_type = type(e).__name__
         error_type = type(e).__name__
-        logger.error(f"Camera connection test failed: {e}")
+        logger.error("Camera connection test failed: %s", e)
         return {"success": False, "error": f"Connection failed: {error_type}"}
         return {"success": False, "error": f"Connection failed: {error_type}"}
 
 
 
 
@@ -517,8 +517,8 @@ async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> As
                 await asyncio.sleep(frame_interval)
                 await asyncio.sleep(frame_interval)
             except asyncio.CancelledError:
             except asyncio.CancelledError:
                 break
                 break
-            except Exception as e:
-                logger.warning(f"Snapshot poll failed: {e}")
+            except (aiohttp.ClientError, OSError) as e:
+                logger.warning("Snapshot poll failed: %s", e)
                 await asyncio.sleep(frame_interval)
                 await asyncio.sleep(frame_interval)
 
 
 
 
@@ -542,14 +542,14 @@ async def _stream_mjpeg(url: str) -> AsyncGenerator[bytes, None]:
     # Sanitize URL - returns reconstructed URL from validated components
     # Sanitize URL - returns reconstructed URL from validated components
     safe_url = _sanitize_camera_url(url, ("http", "https"))
     safe_url = _sanitize_camera_url(url, ("http", "https"))
     if not safe_url:
     if not safe_url:
-        logger.error(f"Invalid MJPEG stream URL: {url[:50]}...")
+        logger.error("Invalid MJPEG stream URL: %s...", url[:50])
         return
         return
 
 
     try:
     try:
         timeout = aiohttp.ClientTimeout(total=None, sock_read=30)
         timeout = aiohttp.ClientTimeout(total=None, sock_read=30)
         async with aiohttp.ClientSession(timeout=timeout) as session, session.get(safe_url) as response:
         async with aiohttp.ClientSession(timeout=timeout) as session, session.get(safe_url) as response:
             if response.status != 200:
             if response.status != 200:
-                logger.error(f"MJPEG stream returned status {response.status}")
+                logger.error("MJPEG stream returned status %s", response.status)
                 return
                 return
 
 
             buffer = b""
             buffer = b""
@@ -579,8 +579,8 @@ async def _stream_mjpeg(url: str) -> AsyncGenerator[bytes, None]:
 
 
     except asyncio.CancelledError:
     except asyncio.CancelledError:
         logger.info("MJPEG stream cancelled")
         logger.info("MJPEG stream cancelled")
-    except Exception as e:
-        logger.error(f"MJPEG stream error: {e}")
+    except (aiohttp.ClientError, OSError) as e:
+        logger.error("MJPEG stream error: %s", e)
 
 
 
 
 async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
 async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
@@ -627,7 +627,7 @@ async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
         await asyncio.sleep(0.5)
         await asyncio.sleep(0.5)
         if process.returncode is not None:
         if process.returncode is not None:
             stderr = await process.stderr.read()
             stderr = await process.stderr.read()
-            logger.error(f"ffmpeg RTSP stream failed immediately: {stderr.decode()[:300]}")
+            logger.error("ffmpeg RTSP stream failed immediately: %s", stderr.decode()[:300])
             return
             return
 
 
         buffer = b""
         buffer = b""
@@ -667,8 +667,8 @@ async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
 
 
     except asyncio.CancelledError:
     except asyncio.CancelledError:
         logger.info("RTSP stream cancelled")
         logger.info("RTSP stream cancelled")
-    except Exception as e:
-        logger.error(f"RTSP stream error: {e}")
+    except OSError as e:
+        logger.error("RTSP stream error: %s", e)
     finally:
     finally:
         if process and process.returncode is None:
         if process and process.returncode is None:
             process.terminate()
             process.terminate()
@@ -688,11 +688,11 @@ async def _stream_usb(device: str, fps: int) -> AsyncGenerator[bytes, None]:
 
 
     # Validate device path
     # Validate device path
     if not device.startswith("/dev/video"):
     if not device.startswith("/dev/video"):
-        logger.error(f"Invalid USB device path: {device}")
+        logger.error("Invalid USB device path: %s", device)
         return
         return
 
 
     if not Path(device).exists():
     if not Path(device).exists():
-        logger.error(f"USB device does not exist: {device}")
+        logger.error("USB device does not exist: %s", device)
         return
         return
 
 
     # ffmpeg command to stream from USB camera (v4l2)
     # ffmpeg command to stream from USB camera (v4l2)
@@ -715,7 +715,7 @@ async def _stream_usb(device: str, fps: int) -> AsyncGenerator[bytes, None]:
 
 
     process = None
     process = None
     try:
     try:
-        logger.info(f"Starting USB camera stream from {device} at {fps} fps")
+        logger.info("Starting USB camera stream from %s at %s fps", device, fps)
         process = await asyncio.create_subprocess_exec(
         process = await asyncio.create_subprocess_exec(
             *cmd,
             *cmd,
             stdout=asyncio.subprocess.PIPE,
             stdout=asyncio.subprocess.PIPE,
@@ -726,7 +726,7 @@ async def _stream_usb(device: str, fps: int) -> AsyncGenerator[bytes, None]:
         await asyncio.sleep(0.5)
         await asyncio.sleep(0.5)
         if process.returncode is not None:
         if process.returncode is not None:
             stderr = await process.stderr.read()
             stderr = await process.stderr.read()
-            logger.error(f"ffmpeg USB stream failed immediately: {stderr.decode()[:300]}")
+            logger.error("ffmpeg USB stream failed immediately: %s", stderr.decode()[:300])
             return
             return
 
 
         buffer = b""
         buffer = b""
@@ -766,8 +766,8 @@ async def _stream_usb(device: str, fps: int) -> AsyncGenerator[bytes, None]:
 
 
     except asyncio.CancelledError:
     except asyncio.CancelledError:
         logger.info("USB stream cancelled")
         logger.info("USB stream cancelled")
-    except Exception as e:
-        logger.error(f"USB stream error: {e}")
+    except OSError as e:
+        logger.error("USB stream error: %s", e)
     finally:
     finally:
         if process and process.returncode is None:
         if process and process.returncode is None:
             process.terminate()
             process.terminate()

+ 17 - 17
backend/app/services/firmware_check.py

@@ -100,11 +100,11 @@ class FirmwareCheckService:
                 if match:
                 if match:
                     self._build_id = match.group(1)
                     self._build_id = match.group(1)
                     self._build_id_time = time.time()
                     self._build_id_time = time.time()
-                    logger.info(f"Got Bambu Lab build ID: {self._build_id}")
+                    logger.info("Got Bambu Lab build ID: %s", self._build_id)
                     return self._build_id
                     return self._build_id
-            logger.warning(f"Failed to get Bambu Lab page: {response.status_code}")
-        except Exception as e:
-            logger.error(f"Error fetching Bambu Lab build ID: {e}")
+            logger.warning("Failed to get Bambu Lab page: %s", response.status_code)
+        except (httpx.HTTPError, OSError) as e:
+            logger.error("Error fetching Bambu Lab build ID: %s", e)
 
 
         return self._build_id  # Return cached value if available
         return self._build_id  # Return cached value if available
 
 
@@ -135,10 +135,10 @@ class FirmwareCheckService:
                         release_time=latest.get("release_time"),
                         release_time=latest.get("release_time"),
                     )
                     )
             else:
             else:
-                logger.warning(f"Failed to fetch firmware for {api_key}: {response.status_code}")
+                logger.warning("Failed to fetch firmware for %s: %s", api_key, response.status_code)
 
 
-        except Exception as e:
-            logger.error(f"Error fetching firmware for {api_key}: {e}")
+        except (httpx.HTTPError, OSError, KeyError, ValueError) as e:
+            logger.error("Error fetching firmware for %s: %s", api_key, e)
 
 
         return None
         return None
 
 
@@ -167,7 +167,7 @@ class FirmwareCheckService:
             api_key = MODEL_TO_API_KEY.get(model)
             api_key = MODEL_TO_API_KEY.get(model)
 
 
         if not api_key:
         if not api_key:
-            logger.debug(f"Unknown printer model: {model}")
+            logger.debug("Unknown printer model: %s", model)
             return None
             return None
 
 
         # Check cache
         # Check cache
@@ -231,7 +231,7 @@ class FirmwareCheckService:
 
 
             result["update_available"] = latest_parts > current_parts
             result["update_available"] = latest_parts > current_parts
         except (ValueError, AttributeError):
         except (ValueError, AttributeError):
-            logger.warning(f"Could not compare versions: {current_version} vs {latest.version}")
+            logger.warning("Could not compare versions: %s vs %s", current_version, latest.version)
 
 
         return result
         return result
 
 
@@ -304,13 +304,13 @@ class FirmwareCheckService:
         """
         """
         latest = await self.get_latest_version(model)
         latest = await self.get_latest_version(model)
         if not latest or not latest.download_url:
         if not latest or not latest.download_url:
-            logger.warning(f"No firmware download URL available for model: {model}")
+            logger.warning("No firmware download URL available for model: %s", model)
             return None
             return None
 
 
         # Check if already cached
         # Check if already cached
         cached_path = self._get_cached_firmware_path(model, latest.version)
         cached_path = self._get_cached_firmware_path(model, latest.version)
         if cached_path.exists():
         if cached_path.exists():
-            logger.info(f"Using cached firmware: {cached_path}")
+            logger.info("Using cached firmware: %s", cached_path)
             return cached_path
             return cached_path
 
 
         # Extract original filename from URL (must preserve for SD card update)
         # Extract original filename from URL (must preserve for SD card update)
@@ -321,13 +321,13 @@ class FirmwareCheckService:
         temp_path = self._get_firmware_cache_dir() / f".downloading_{original_filename}"
         temp_path = self._get_firmware_cache_dir() / f".downloading_{original_filename}"
 
 
         try:
         try:
-            logger.info(f"Downloading firmware from {latest.download_url}")
+            logger.info("Downloading firmware from %s", latest.download_url)
             if progress_callback:
             if progress_callback:
                 progress_callback(0, 0, "Starting download...")
                 progress_callback(0, 0, "Starting download...")
 
 
             async with self._client.stream("GET", latest.download_url) as response:
             async with self._client.stream("GET", latest.download_url) as response:
                 if response.status_code != 200:
                 if response.status_code != 200:
-                    logger.error(f"Firmware download failed with status {response.status_code}")
+                    logger.error("Firmware download failed with status %s", response.status_code)
                     return None
                     return None
 
 
                 total_size = int(response.headers.get("content-length", 0))
                 total_size = int(response.headers.get("content-length", 0))
@@ -351,18 +351,18 @@ class FirmwareCheckService:
             shutil.copy2(temp_path, cached_path)
             shutil.copy2(temp_path, cached_path)
             temp_path.rename(original_path)
             temp_path.rename(original_path)
 
 
-            logger.info(f"Firmware downloaded successfully: {original_path}")
+            logger.info("Firmware downloaded successfully: %s", original_path)
             if progress_callback:
             if progress_callback:
                 progress_callback(downloaded, total_size, "Download complete")
                 progress_callback(downloaded, total_size, "Download complete")
 
 
             return original_path
             return original_path
 
 
-        except Exception as e:
-            logger.error(f"Firmware download failed: {e}")
+        except (httpx.HTTPError, OSError) as e:
+            logger.error("Firmware download failed: %s", e)
             if temp_path.exists():
             if temp_path.exists():
                 try:
                 try:
                     temp_path.unlink()
                     temp_path.unlink()
-                except Exception:
+                except OSError:
                     pass
                     pass
             return None
             return None
 
 

+ 5 - 5
backend/app/services/firmware_update.py

@@ -144,7 +144,7 @@ class FirmwareUpdateService:
                 if storage_info and "free_bytes" in storage_info:
                 if storage_info and "free_bytes" in storage_info:
                     result["sd_card_free_space"] = storage_info["free_bytes"]
                     result["sd_card_free_space"] = storage_info["free_bytes"]
             except Exception as e:
             except Exception as e:
-                logger.warning(f"Could not get storage info: {e}")
+                logger.warning("Could not get storage info: %s", e)
 
 
         # Check for firmware update
         # Check for firmware update
         firmware_service = get_firmware_service()
         firmware_service = get_firmware_service()
@@ -211,7 +211,7 @@ class FirmwareUpdateService:
 
 
         # Check if already in progress
         # Check if already in progress
         if state.status in (FirmwareUploadStatus.DOWNLOADING, FirmwareUploadStatus.UPLOADING):
         if state.status in (FirmwareUploadStatus.DOWNLOADING, FirmwareUploadStatus.UPLOADING):
-            logger.warning(f"Firmware upload already in progress for printer {printer_id}")
+            logger.warning("Firmware upload already in progress for printer %s", printer_id)
             return False
             return False
 
 
         # Get printer
         # Get printer
@@ -285,7 +285,7 @@ class FirmwareUpdateService:
             # Upload to root of SD card (where printer expects firmware)
             # Upload to root of SD card (where printer expects firmware)
             remote_path = f"/{firmware_path.name}"
             remote_path = f"/{firmware_path.name}"
 
 
-            logger.info(f"Uploading firmware to printer {printer_id}: {remote_path}")
+            logger.info("Uploading firmware to printer %s: %s", printer_id, remote_path)
 
 
             # Track real progress via FTP callback
             # Track real progress via FTP callback
             loop = asyncio.get_event_loop()
             loop = asyncio.get_event_loop()
@@ -341,10 +341,10 @@ class FirmwareUpdateService:
             )
             )
             await self._broadcast_progress(printer_id, state)
             await self._broadcast_progress(printer_id, state)
 
 
-            logger.info(f"Firmware upload complete for printer {printer_id}")
+            logger.info("Firmware upload complete for printer %s", printer_id)
 
 
         except Exception as e:
         except Exception as e:
-            logger.error(f"Firmware upload failed for printer {printer_id}: {e}")
+            logger.error("Firmware upload failed for printer %s: %s", printer_id, e)
             state.status = FirmwareUploadStatus.ERROR
             state.status = FirmwareUploadStatus.ERROR
             state.error = str(e)
             state.error = str(e)
             state.message = f"Firmware upload failed: {e}"
             state.message = f"Firmware upload failed: {e}"

+ 9 - 9
backend/app/services/github_backup.py

@@ -71,7 +71,7 @@ class GitHubBackupService:
             except asyncio.CancelledError:
             except asyncio.CancelledError:
                 break
                 break
             except Exception as e:
             except Exception as e:
-                logger.error(f"Error in GitHub backup scheduler: {e}")
+                logger.error("Error in GitHub backup scheduler: %s", e)
                 await asyncio.sleep(60)
                 await asyncio.sleep(60)
 
 
     async def _check_scheduled_backups(self):
     async def _check_scheduled_backups(self):
@@ -92,7 +92,7 @@ class GitHubBackupService:
                 if next_run and next_run.tzinfo is None:
                 if next_run and next_run.tzinfo is None:
                     next_run = next_run.replace(tzinfo=timezone.utc)
                     next_run = next_run.replace(tzinfo=timezone.utc)
                 if next_run and next_run <= now:
                 if next_run and next_run <= now:
-                    logger.info(f"Running scheduled backup for config {config.id}")
+                    logger.info("Running scheduled backup for config %s", config.id)
                     await self.run_backup(config.id, trigger="scheduled")
                     await self.run_backup(config.id, trigger="scheduled")
 
 
     def _calculate_next_run(self, schedule_type: str, from_time: datetime | None = None) -> datetime:
     def _calculate_next_run(self, schedule_type: str, from_time: datetime | None = None) -> datetime:
@@ -164,7 +164,7 @@ class GitHubBackupService:
             }
             }
 
 
         except Exception as e:
         except Exception as e:
-            logger.error(f"GitHub connection test failed: {e}")
+            logger.error("GitHub connection test failed: %s", e)
             # Sanitize error - don't expose internal details
             # Sanitize error - don't expose internal details
             error_type = type(e).__name__
             error_type = type(e).__name__
             return {
             return {
@@ -283,7 +283,7 @@ class GitHubBackupService:
                     }
                     }
 
 
                 except Exception as e:
                 except Exception as e:
-                    logger.error(f"Backup failed: {e}")
+                    logger.error("Backup failed: %s", e)
                     log.status = "failed"
                     log.status = "failed"
                     log.completed_at = datetime.now(timezone.utc)
                     log.completed_at = datetime.now(timezone.utc)
                     log.error_message = str(e)
                     log.error_message = str(e)
@@ -393,10 +393,10 @@ class GitHubBackupService:
                         files[f"kprofiles/{serial}/{nozzle}.json"] = profile_data
                         files[f"kprofiles/{serial}/{nozzle}.json"] = profile_data
                         printer_profiles[nozzle] = len(profiles)
                         printer_profiles[nozzle] = len(profiles)
                 except Exception as e:
                 except Exception as e:
-                    logger.warning(f"Failed to get K-profiles for printer {serial} nozzle {nozzle}: {e}")
+                    logger.warning("Failed to get K-profiles for printer %s nozzle %s: %s", serial, nozzle, e)
 
 
             if printer_profiles:
             if printer_profiles:
-                logger.info(f"Collected K-profiles for {serial}: {printer_profiles}")
+                logger.info("Collected K-profiles for %s: %s", serial, printer_profiles)
 
 
     async def _collect_cloud_profiles(self, db: AsyncSession, files: dict):
     async def _collect_cloud_profiles(self, db: AsyncSession, files: dict):
         """Collect Bambu Cloud profiles if authenticated."""
         """Collect Bambu Cloud profiles if authenticated."""
@@ -456,7 +456,7 @@ class GitHubBackupService:
             )
             )
 
 
         except Exception as e:
         except Exception as e:
-            logger.warning(f"Failed to collect cloud profiles: {e}")
+            logger.warning("Failed to collect cloud profiles: %s", e)
 
 
     async def _collect_settings(self, db: AsyncSession, files: dict):
     async def _collect_settings(self, db: AsyncSession, files: dict):
         """Collect app settings."""
         """Collect app settings."""
@@ -551,7 +551,7 @@ class GitHubBackupService:
                 )
                 )
 
 
                 if blob_response.status_code != 201:
                 if blob_response.status_code != 201:
-                    logger.error(f"Failed to create blob for {path}: {blob_response.text}")
+                    logger.error("Failed to create blob for %s: %s", path, blob_response.text)
                     continue
                     continue
 
 
                 blob_sha = blob_response.json()["sha"]
                 blob_sha = blob_response.json()["sha"]
@@ -604,7 +604,7 @@ class GitHubBackupService:
             }
             }
 
 
         except Exception as e:
         except Exception as e:
-            logger.error(f"Push to GitHub failed: {e}")
+            logger.error("Push to GitHub failed: %s", e)
             return {"status": "failed", "message": str(e), "error": str(e)}
             return {"status": "failed", "message": str(e), "error": str(e)}
 
 
     async def _create_branch_and_push(
     async def _create_branch_and_push(

+ 14 - 13
backend/app/services/homeassistant.py

@@ -64,29 +64,29 @@ class HomeAssistantService:
                     "reachable": True,
                     "reachable": True,
                     "device_name": data.get("attributes", {}).get("friendly_name"),
                     "device_name": data.get("attributes", {}).get("friendly_name"),
                 }
                 }
-        except Exception as e:
-            logger.warning(f"Failed to get HA entity state for {plug.ha_entity_id}: {e}")
+        except (httpx.HTTPError, OSError, KeyError) as e:
+            logger.warning("Failed to get HA entity state for %s: %s", plug.ha_entity_id, e)
             return {"state": None, "reachable": False, "device_name": None}
             return {"state": None, "reachable": False, "device_name": None}
 
 
     async def turn_on(self, plug: "SmartPlug") -> bool:
     async def turn_on(self, plug: "SmartPlug") -> bool:
         """Turn on HA entity. Returns True if successful."""
         """Turn on HA entity. Returns True if successful."""
         success = await self._call_service(plug, "turn_on")
         success = await self._call_service(plug, "turn_on")
         if success:
         if success:
-            logger.info(f"Turned ON HA entity '{plug.name}' ({plug.ha_entity_id})")
+            logger.info("Turned ON HA entity '%s' (%s)", plug.name, plug.ha_entity_id)
         return success
         return success
 
 
     async def turn_off(self, plug: "SmartPlug") -> bool:
     async def turn_off(self, plug: "SmartPlug") -> bool:
         """Turn off HA entity. Returns True if successful."""
         """Turn off HA entity. Returns True if successful."""
         success = await self._call_service(plug, "turn_off")
         success = await self._call_service(plug, "turn_off")
         if success:
         if success:
-            logger.info(f"Turned OFF HA entity '{plug.name}' ({plug.ha_entity_id})")
+            logger.info("Turned OFF HA entity '%s' (%s)", plug.name, plug.ha_entity_id)
         return success
         return success
 
 
     async def toggle(self, plug: "SmartPlug") -> bool:
     async def toggle(self, plug: "SmartPlug") -> bool:
         """Toggle HA entity. Returns True if successful."""
         """Toggle HA entity. Returns True if successful."""
         success = await self._call_service(plug, "toggle")
         success = await self._call_service(plug, "toggle")
         if success:
         if success:
-            logger.info(f"Toggled HA entity '{plug.name}' ({plug.ha_entity_id})")
+            logger.info("Toggled HA entity '%s' (%s)", plug.name, plug.ha_entity_id)
         return success
         return success
 
 
     async def _call_service(self, plug: "SmartPlug", action: str) -> bool:
     async def _call_service(self, plug: "SmartPlug", action: str) -> bool:
@@ -105,8 +105,8 @@ class HomeAssistantService:
                 )
                 )
                 response.raise_for_status()
                 response.raise_for_status()
                 return True
                 return True
-        except Exception as e:
-            logger.warning(f"Failed to {action} HA entity {plug.ha_entity_id}: {e}")
+        except (httpx.HTTPError, OSError) as e:
+            logger.warning("Failed to %s HA entity %s: %s", action, plug.ha_entity_id, e)
             return False
             return False
 
 
     async def get_energy(self, plug: "SmartPlug") -> dict | None:
     async def get_energy(self, plug: "SmartPlug") -> dict | None:
@@ -165,7 +165,8 @@ class HomeAssistantService:
                     "apparent_power": None,
                     "apparent_power": None,
                     "reactive_power": None,
                     "reactive_power": None,
                 }
                 }
-        except Exception:
+        except (httpx.HTTPError, OSError, KeyError, ValueError) as e:
+            logger.debug("Failed to get HA energy data: %s", e)
             return None
             return None
 
 
     async def _get_sensor_value(self, client: httpx.AsyncClient, entity_id: str) -> float | None:
     async def _get_sensor_value(self, client: httpx.AsyncClient, entity_id: str) -> float | None:
@@ -179,7 +180,7 @@ class HomeAssistantService:
             state = response.json().get("state")
             state = response.json().get("state")
             if state and state not in ("unknown", "unavailable"):
             if state and state not in ("unknown", "unavailable"):
                 return float(state)
                 return float(state)
-        except Exception:
+        except (httpx.HTTPError, OSError, ValueError):
             pass
             pass
         return None
         return None
 
 
@@ -265,8 +266,8 @@ class HomeAssistantService:
                     )
                     )
 
 
                 return sorted(entities, key=lambda x: x["friendly_name"].lower())
                 return sorted(entities, key=lambda x: x["friendly_name"].lower())
-        except Exception as e:
-            logger.warning(f"Failed to list HA entities: {e}")
+        except (httpx.HTTPError, OSError, KeyError) as e:
+            logger.warning("Failed to list HA entities: %s", e)
             return []
             return []
 
 
     async def list_sensor_entities(self, url: str, token: str) -> list[dict]:
     async def list_sensor_entities(self, url: str, token: str) -> list[dict]:
@@ -311,8 +312,8 @@ class HomeAssistantService:
                         )
                         )
 
 
                 return sorted(entities, key=lambda x: x["friendly_name"].lower())
                 return sorted(entities, key=lambda x: x["friendly_name"].lower())
-        except Exception as e:
-            logger.warning(f"Failed to list HA sensor entities: {e}")
+        except (httpx.HTTPError, OSError, KeyError) as e:
+            logger.warning("Failed to list HA sensor entities: %s", e)
             return []
             return []
 
 
 
 

+ 16 - 14
backend/app/services/layer_timelapse.py

@@ -48,7 +48,7 @@ class TimelapseSession:
     def __post_init__(self):
     def __post_init__(self):
         self.frames_dir = settings.base_dir / "timelapse_frames" / str(self.printer_id) / self.session_id
         self.frames_dir = settings.base_dir / "timelapse_frames" / str(self.printer_id) / self.session_id
         self.frames_dir.mkdir(parents=True, exist_ok=True)
         self.frames_dir.mkdir(parents=True, exist_ok=True)
-        logger.info(f"Created timelapse session {self.session_id} for printer {self.printer_id}")
+        logger.info("Created timelapse session %s for printer %s", self.session_id, self.printer_id)
 
 
     async def capture_layer(self, layer_num: int) -> bool:
     async def capture_layer(self, layer_num: int) -> bool:
         """Capture frame if layer changed.
         """Capture frame if layer changed.
@@ -71,13 +71,15 @@ class TimelapseSession:
                 frame_path = self.frames_dir / f"layer_{layer_num:05d}.jpg"
                 frame_path = self.frames_dir / f"layer_{layer_num:05d}.jpg"
                 await asyncio.to_thread(frame_path.write_bytes, frame_data)
                 await asyncio.to_thread(frame_path.write_bytes, frame_data)
                 self.frame_count += 1
                 self.frame_count += 1
-                logger.debug(f"Captured layer {layer_num} for printer {self.printer_id} (frame {self.frame_count})")
+                logger.debug(
+                    "Captured layer %s for printer %s (frame %s)", layer_num, self.printer_id, self.frame_count
+                )
                 return True
                 return True
             else:
             else:
-                logger.warning(f"Failed to capture frame for layer {layer_num}")
+                logger.warning("Failed to capture frame for layer %s", layer_num)
                 return False
                 return False
         except Exception as e:
         except Exception as e:
-            logger.error(f"Error capturing timelapse frame: {e}")
+            logger.error("Error capturing timelapse frame: %s", e)
             return False
             return False
 
 
     async def stitch(self, output_path: Path, fps: int = 30) -> bool:
     async def stitch(self, output_path: Path, fps: int = 30) -> bool:
@@ -118,7 +120,7 @@ class TimelapseSession:
                 if frame_files:
                 if frame_files:
                     f.write(f"file '{frame_files[-1].name}'\n")
                     f.write(f"file '{frame_files[-1].name}'\n")
         except Exception as e:
         except Exception as e:
-            logger.error(f"Failed to create concat file: {e}")
+            logger.error("Failed to create concat file: %s", e)
             return False
             return False
 
 
         # Use ffmpeg concat demuxer for variable-gap frame sequences
         # Use ffmpeg concat demuxer for variable-gap frame sequences
@@ -153,10 +155,10 @@ class TimelapseSession:
             stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=300)
             stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=300)
 
 
             if process.returncode != 0:
             if process.returncode != 0:
-                logger.error(f"ffmpeg timelapse stitch failed: {stderr.decode()[:500]}")
+                logger.error("ffmpeg timelapse stitch failed: %s", stderr.decode()[:500])
                 return False
                 return False
 
 
-            logger.info(f"Created timelapse video: {output_path} ({self.frame_count} frames)")
+            logger.info("Created timelapse video: %s (%s frames)", output_path, self.frame_count)
             return True
             return True
 
 
         except TimeoutError:
         except TimeoutError:
@@ -165,7 +167,7 @@ class TimelapseSession:
                 process.kill()
                 process.kill()
             return False
             return False
         except Exception as e:
         except Exception as e:
-            logger.error(f"Timelapse stitch failed: {e}")
+            logger.error("Timelapse stitch failed: %s", e)
             return False
             return False
 
 
     def cleanup(self):
     def cleanup(self):
@@ -173,9 +175,9 @@ class TimelapseSession:
         try:
         try:
             if self.frames_dir.exists():
             if self.frames_dir.exists():
                 shutil.rmtree(self.frames_dir, ignore_errors=True)
                 shutil.rmtree(self.frames_dir, ignore_errors=True)
-                logger.info(f"Cleaned up timelapse frames for session {self.session_id}")
+                logger.info("Cleaned up timelapse frames for session %s", self.session_id)
         except Exception as e:
         except Exception as e:
-            logger.warning(f"Failed to cleanup timelapse frames: {e}")
+            logger.warning("Failed to cleanup timelapse frames: %s", e)
 
 
 
 
 def start_session(printer_id: int, archive_id: int | None, url: str, cam_type: str) -> TimelapseSession:
 def start_session(printer_id: int, archive_id: int | None, url: str, cam_type: str) -> TimelapseSession:
@@ -200,7 +202,7 @@ def start_session(printer_id: int, archive_id: int | None, url: str, cam_type: s
         camera_type=cam_type,
         camera_type=cam_type,
     )
     )
     _active_sessions[printer_id] = session
     _active_sessions[printer_id] = session
-    logger.info(f"Started timelapse session for printer {printer_id}")
+    logger.info("Started timelapse session for printer %s", printer_id)
     return session
     return session
 
 
 
 
@@ -235,7 +237,7 @@ async def on_print_complete(printer_id: int) -> Path | None:
         return None
         return None
 
 
     if session.frame_count == 0:
     if session.frame_count == 0:
-        logger.info(f"No timelapse frames captured for printer {printer_id}")
+        logger.info("No timelapse frames captured for printer %s", printer_id)
         session.cleanup()
         session.cleanup()
         return None
         return None
 
 
@@ -252,7 +254,7 @@ async def on_print_complete(printer_id: int) -> Path | None:
             session.cleanup()
             session.cleanup()
             return None
             return None
     except Exception as e:
     except Exception as e:
-        logger.error(f"Timelapse completion failed: {e}")
+        logger.error("Timelapse completion failed: %s", e)
         session.cleanup()
         session.cleanup()
         return None
         return None
 
 
@@ -266,7 +268,7 @@ def cancel_session(printer_id: int):
     session = _active_sessions.pop(printer_id, None)
     session = _active_sessions.pop(printer_id, None)
     if session:
     if session:
         session.cleanup()
         session.cleanup()
-        logger.info(f"Cancelled timelapse session for printer {printer_id}")
+        logger.info("Cancelled timelapse session for printer %s", printer_id)
 
 
 
 
 def get_active_sessions() -> dict[int, TimelapseSession]:
 def get_active_sessions() -> dict[int, TimelapseSession]:

+ 9 - 9
backend/app/services/mqtt_relay.py

@@ -89,7 +89,7 @@ class MQTTRelayService:
 
 
             await self._smart_plug_service.configure(settings)
             await self._smart_plug_service.configure(settings)
         except Exception as e:
         except Exception as e:
-            logger.error(f"Failed to configure MQTT smart plug service: {e}")
+            logger.error("Failed to configure MQTT smart plug service: %s", e)
 
 
     @property
     @property
     def smart_plug_service(self):
     def smart_plug_service(self):
@@ -128,7 +128,7 @@ class MQTTRelayService:
             try:
             try:
                 await asyncio.wait_for(asyncio.to_thread(self.client.connect_async, broker, port, 60), timeout=3.0)
                 await asyncio.wait_for(asyncio.to_thread(self.client.connect_async, broker, port, 60), timeout=3.0)
             except TimeoutError:
             except TimeoutError:
-                logger.warning(f"MQTT relay connection to {broker}:{port} timed out")
+                logger.warning("MQTT relay connection to %s:%s timed out", broker, port)
                 return False
                 return False
 
 
             self.client.loop_start()
             self.client.loop_start()
@@ -137,16 +137,16 @@ class MQTTRelayService:
             await asyncio.sleep(1.0)
             await asyncio.sleep(1.0)
 
 
             if self.connected:
             if self.connected:
-                logger.info(f"MQTT relay connected to {broker}:{port}")
+                logger.info("MQTT relay connected to %s:%s", broker, port)
                 # Publish online status
                 # Publish online status
                 self._publish_status("online")
                 self._publish_status("online")
                 return True
                 return True
             else:
             else:
-                logger.warning(f"MQTT relay connection pending to {broker}:{port}")
+                logger.warning("MQTT relay connection pending to %s:%s", broker, port)
                 return True  # Connection is async, may succeed later
                 return True  # Connection is async, may succeed later
 
 
         except Exception as e:
         except Exception as e:
-            logger.error(f"MQTT relay connection failed: {e}")
+            logger.error("MQTT relay connection failed: %s", e)
             self.connected = False
             self.connected = False
             return False
             return False
 
 
@@ -168,7 +168,7 @@ class MQTTRelayService:
             self._publish_status("online")
             self._publish_status("online")
         else:
         else:
             self.connected = False
             self.connected = False
-            logger.error(f"MQTT relay connection failed: {reason_code}")
+            logger.error("MQTT relay connection failed: %s", reason_code)
 
 
     def _on_disconnect(
     def _on_disconnect(
         self,
         self,
@@ -184,7 +184,7 @@ class MQTTRelayService:
         rc = reason_code if reason_code is not None else flags_or_rc
         rc = reason_code if reason_code is not None else flags_or_rc
         rc_val = rc if isinstance(rc, int) else getattr(rc, "value", 0)
         rc_val = rc if isinstance(rc, int) else getattr(rc, "value", 0)
         if rc_val != 0:
         if rc_val != 0:
-            logger.warning(f"MQTT relay disconnected: {rc}")
+            logger.warning("MQTT relay disconnected: %s", rc)
         else:
         else:
             logger.info("MQTT relay disconnected cleanly")
             logger.info("MQTT relay disconnected cleanly")
 
 
@@ -197,7 +197,7 @@ class MQTTRelayService:
                 self.client.loop_stop()
                 self.client.loop_stop()
                 self.client.disconnect()
                 self.client.disconnect()
             except Exception as e:
             except Exception as e:
-                logger.debug(f"MQTT disconnect error (ignored): {e}")
+                logger.debug("MQTT disconnect error (ignored): %s", e)
             finally:
             finally:
                 self.client = None
                 self.client = None
                 self.connected = False
                 self.connected = False
@@ -219,7 +219,7 @@ class MQTTRelayService:
             with self._lock:
             with self._lock:
                 self.client.publish(topic, json.dumps(payload, default=str), qos=1, retain=retain)
                 self.client.publish(topic, json.dumps(payload, default=str), qos=1, retain=retain)
         except Exception as e:
         except Exception as e:
-            logger.debug(f"MQTT publish error: {e}")
+            logger.debug("MQTT publish error: %s", e)
 
 
     def get_status(self) -> dict:
     def get_status(self) -> dict:
         """Get current MQTT relay status for API."""
         """Get current MQTT relay status for API."""

+ 17 - 17
backend/app/services/mqtt_smart_plug.py

@@ -152,7 +152,7 @@ class MQTTSmartPlugService:
                     timeout=3.0,
                     timeout=3.0,
                 )
                 )
             except TimeoutError:
             except TimeoutError:
-                logger.warning(f"MQTT smart plug connection to {self._broker}:{self._port} timed out")
+                logger.warning("MQTT smart plug connection to %s:%s timed out", self._broker, self._port)
                 return False
                 return False
 
 
             self.client.loop_start()
             self.client.loop_start()
@@ -161,16 +161,16 @@ class MQTTSmartPlugService:
             await asyncio.sleep(1.0)
             await asyncio.sleep(1.0)
 
 
             if self.connected:
             if self.connected:
-                logger.info(f"MQTT smart plug service connected to {self._broker}:{self._port}")
+                logger.info("MQTT smart plug service connected to %s:%s", self._broker, self._port)
                 # Resubscribe to all topics
                 # Resubscribe to all topics
                 self._resubscribe_all()
                 self._resubscribe_all()
                 return True
                 return True
             else:
             else:
-                logger.warning(f"MQTT smart plug connection pending to {self._broker}:{self._port}")
+                logger.warning("MQTT smart plug connection pending to %s:%s", self._broker, self._port)
                 return True  # Connection is async
                 return True  # Connection is async
 
 
         except Exception as e:
         except Exception as e:
-            logger.error(f"MQTT smart plug connection failed: {e}")
+            logger.error("MQTT smart plug connection failed: %s", e)
             self.connected = False
             self.connected = False
             return False
             return False
 
 
@@ -191,7 +191,7 @@ class MQTTSmartPlugService:
             self._resubscribe_all()
             self._resubscribe_all()
         else:
         else:
             self.connected = False
             self.connected = False
-            logger.error(f"MQTT smart plug connection failed: {reason_code}")
+            logger.error("MQTT smart plug connection failed: %s", reason_code)
 
 
     def _on_disconnect(
     def _on_disconnect(
         self,
         self,
@@ -206,7 +206,7 @@ class MQTTSmartPlugService:
         rc = reason_code if reason_code is not None else flags_or_rc
         rc = reason_code if reason_code is not None else flags_or_rc
         rc_val = rc if isinstance(rc, int) else getattr(rc, "value", 0)
         rc_val = rc if isinstance(rc, int) else getattr(rc, "value", 0)
         if rc_val != 0:
         if rc_val != 0:
-            logger.warning(f"MQTT smart plug service disconnected: {rc}")
+            logger.warning("MQTT smart plug service disconnected: %s", rc)
         else:
         else:
             logger.info("MQTT smart plug service disconnected cleanly")
             logger.info("MQTT smart plug service disconnected cleanly")
 
 
@@ -244,7 +244,7 @@ class MQTTSmartPlugService:
                         raw_value = payload
                         raw_value = payload
                     else:
                     else:
                         # Can't use a dict/list as a value
                         # Can't use a dict/list as a value
-                        logger.debug(f"MQTT plug {plug_id}: JSON payload is object/array but no path configured")
+                        logger.debug("MQTT plug %s: JSON payload is object/array but no path configured", plug_id)
                         continue
                         continue
                 else:
                 else:
                     # Raw value (non-JSON)
                     # Raw value (non-JSON)
@@ -264,14 +264,14 @@ class MQTTSmartPlugService:
                 if data_type == "power":
                 if data_type == "power":
                     try:
                     try:
                         data.power = float(raw_value) * config.multiplier
                         data.power = float(raw_value) * config.multiplier
-                        logger.debug(f"MQTT smart plug {plug_id}: power={data.power}")
+                        logger.debug("MQTT smart plug %s: power=%s", plug_id, data.power)
                     except (ValueError, TypeError):
                     except (ValueError, TypeError):
                         pass
                         pass
 
 
                 elif data_type == "energy":
                 elif data_type == "energy":
                     try:
                     try:
                         data.energy = float(raw_value) * config.multiplier
                         data.energy = float(raw_value) * config.multiplier
-                        logger.debug(f"MQTT smart plug {plug_id}: energy={data.energy}")
+                        logger.debug("MQTT smart plug %s: energy=%s", plug_id, data.energy)
                     except (ValueError, TypeError):
                     except (ValueError, TypeError):
                         pass
                         pass
 
 
@@ -293,7 +293,7 @@ class MQTTSmartPlugService:
                             data.state = "OFF"
                             data.state = "OFF"
                         else:
                         else:
                             data.state = state_str
                             data.state = state_str
-                    logger.debug(f"MQTT smart plug {plug_id}: state={data.state}")
+                    logger.debug("MQTT smart plug %s: state=%s", plug_id, data.state)
 
 
     def _extract_json_path(self, data: dict, path: str) -> Any:
     def _extract_json_path(self, data: dict, path: str) -> Any:
         """Extract value using dot notation (e.g., 'power_l1' or 'data.power').
         """Extract value using dot notation (e.g., 'power_l1' or 'data.power').
@@ -324,9 +324,9 @@ class MQTTSmartPlugService:
                 if self.subscriptions[topic]:  # Only if there are subscribers
                 if self.subscriptions[topic]:  # Only if there are subscribers
                     try:
                     try:
                         self.client.subscribe(topic, qos=1)
                         self.client.subscribe(topic, qos=1)
-                        logger.debug(f"MQTT smart plug: resubscribed to {topic}")
+                        logger.debug("MQTT smart plug: resubscribed to %s", topic)
                     except Exception as e:
                     except Exception as e:
-                        logger.error(f"MQTT smart plug: failed to resubscribe to {topic}: {e}")
+                        logger.error("MQTT smart plug: failed to resubscribe to %s: %s", topic, e)
 
 
     def subscribe(
     def subscribe(
         self,
         self,
@@ -415,9 +415,9 @@ class MQTTSmartPlugService:
             if self.client and self.connected:
             if self.client and self.connected:
                 try:
                 try:
                     self.client.subscribe(topic, qos=1)
                     self.client.subscribe(topic, qos=1)
-                    logger.info(f"MQTT smart plug: subscribed to {topic}")
+                    logger.info("MQTT smart plug: subscribed to %s", topic)
                 except Exception as e:
                 except Exception as e:
-                    logger.error(f"MQTT smart plug: failed to subscribe to {topic}: {e}")
+                    logger.error("MQTT smart plug: failed to subscribe to %s: %s", topic, e)
 
 
         entry = (plug_id, data_type)
         entry = (plug_id, data_type)
         if entry not in self.subscriptions[topic]:
         if entry not in self.subscriptions[topic]:
@@ -450,9 +450,9 @@ class MQTTSmartPlugService:
                     if self.client and self.connected:
                     if self.client and self.connected:
                         try:
                         try:
                             self.client.unsubscribe(topic)
                             self.client.unsubscribe(topic)
-                            logger.info(f"MQTT smart plug: unsubscribed from {topic}")
+                            logger.info("MQTT smart plug: unsubscribed from %s", topic)
                         except Exception as e:
                         except Exception as e:
-                            logger.error(f"MQTT smart plug: failed to unsubscribe from {topic}: {e}")
+                            logger.error("MQTT smart plug: failed to unsubscribe from %s: %s", topic, e)
 
 
             # Remove data
             # Remove data
             self.plug_data.pop(plug_id, None)
             self.plug_data.pop(plug_id, None)
@@ -478,7 +478,7 @@ class MQTTSmartPlugService:
                 self.client.loop_stop()
                 self.client.loop_stop()
                 self.client.disconnect()
                 self.client.disconnect()
             except Exception as e:
             except Exception as e:
-                logger.debug(f"MQTT smart plug disconnect error (ignored): {e}")
+                logger.debug("MQTT smart plug disconnect error (ignored): %s", e)
             finally:
             finally:
                 self.client = None
                 self.client = None
                 self.connected = False
                 self.connected = False

+ 5 - 5
backend/app/services/network_utils.py

@@ -65,13 +65,13 @@ def get_network_interfaces() -> list[dict]:
                 # Interface doesn't have an IP or other error
                 # Interface doesn't have an IP or other error
                 pass
                 pass
             except Exception as e:
             except Exception as e:
-                logger.debug(f"Error getting info for interface {name}: {e}")
+                logger.debug("Error getting info for interface %s: %s", name, e)
 
 
     except ImportError:
     except ImportError:
         # fcntl not available (Windows)
         # fcntl not available (Windows)
         logger.warning("fcntl not available, interface detection limited")
         logger.warning("fcntl not available, interface detection limited")
     except Exception as e:
     except Exception as e:
-        logger.error(f"Error enumerating interfaces: {e}")
+        logger.error("Error enumerating interfaces: %s", e)
 
 
     return interfaces
     return interfaces
 
 
@@ -88,7 +88,7 @@ def find_interface_for_ip(target_ip: str) -> dict | None:
     try:
     try:
         target = ipaddress.IPv4Address(target_ip)
         target = ipaddress.IPv4Address(target_ip)
     except ValueError:
     except ValueError:
-        logger.error(f"Invalid target IP: {target_ip}")
+        logger.error("Invalid target IP: %s", target_ip)
         return None
         return None
 
 
     interfaces = get_network_interfaces()
     interfaces = get_network_interfaces()
@@ -97,12 +97,12 @@ def find_interface_for_ip(target_ip: str) -> dict | None:
         try:
         try:
             network = ipaddress.IPv4Network(iface["subnet"], strict=False)
             network = ipaddress.IPv4Network(iface["subnet"], strict=False)
             if target in network:
             if target in network:
-                logger.debug(f"Found interface {iface['name']} ({iface['ip']}) for target {target_ip}")
+                logger.debug("Found interface %s (%s) for target %s", iface["name"], iface["ip"], target_ip)
                 return iface
                 return iface
         except ValueError:
         except ValueError:
             continue
             continue
 
 
-    logger.warning(f"No interface found for target IP {target_ip}")
+    logger.warning("No interface found for target IP %s", target_ip)
     return None
     return None
 
 
 
 

+ 28 - 28
backend/app/services/notification_service.py

@@ -67,7 +67,7 @@ class NotificationService:
                 # Same day quiet hours
                 # Same day quiet hours
                 return start_minutes <= current_time < end_minutes
                 return start_minutes <= current_time < end_minutes
         except (ValueError, TypeError, AttributeError):
         except (ValueError, TypeError, AttributeError):
-            logger.warning(f"Invalid quiet hours format for provider {provider.name}")
+            logger.warning("Invalid quiet hours format for provider %s", provider.name)
             return False
             return False
 
 
     async def _get_template(self, db: AsyncSession, event_type: str) -> NotificationTemplate | None:
     async def _get_template(self, db: AsyncSession, event_type: str) -> NotificationTemplate | None:
@@ -129,7 +129,7 @@ class NotificationService:
         template = await self._get_template(db, event_type)
         template = await self._get_template(db, event_type)
         if not template:
         if not template:
             # Fallback to simple message
             # Fallback to simple message
-            logger.warning(f"Template not found for event type: {event_type}")
+            logger.warning("Template not found for event type: %s", event_type)
             return event_type.replace("_", " ").title(), str(variables)
             return event_type.replace("_", " ").title(), str(variables)
 
 
         title = self._render_template(template.title_template, variables)
         title = self._render_template(template.title_template, variables)
@@ -165,7 +165,7 @@ class NotificationService:
             else:
             else:
                 return False, f"Unknown provider type: {provider_type}"
                 return False, f"Unknown provider type: {provider_type}"
         except Exception as e:
         except Exception as e:
-            logger.exception(f"Error sending test notification via {provider_type}")
+            logger.exception("Error sending test notification via %s", provider_type)
             return False, str(e)
             return False, str(e)
 
 
     async def _send_callmebot(self, config: dict, message: str) -> tuple[bool, str]:
     async def _send_callmebot(self, config: dict, message: str) -> tuple[bool, str]:
@@ -432,7 +432,7 @@ class NotificationService:
         """Send notification to a specific provider."""
         """Send notification to a specific provider."""
         # Check quiet hours
         # Check quiet hours
         if self._is_in_quiet_hours(provider):
         if self._is_in_quiet_hours(provider):
-            logger.info(f"Skipping notification to {provider.name} - quiet hours active")
+            logger.info("Skipping notification to %s - quiet hours active", provider.name)
             return True, "Skipped - quiet hours"
             return True, "Skipped - quiet hours"
 
 
         config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
         config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
@@ -455,7 +455,7 @@ class NotificationService:
             else:
             else:
                 return False, f"Unknown provider type: {provider.provider_type}"
                 return False, f"Unknown provider type: {provider.provider_type}"
         except Exception as e:
         except Exception as e:
-            logger.exception(f"Error sending notification via {provider.provider_type}")
+            logger.exception("Error sending notification via %s", provider.provider_type)
             return False, str(e)
             return False, str(e)
 
 
     async def _update_provider_status(
     async def _update_provider_status(
@@ -520,7 +520,7 @@ class NotificationService:
             db.add(log)
             db.add(log)
             await db.commit()
             await db.commit()
         except Exception as e:
         except Exception as e:
-            logger.warning(f"Failed to log notification: {e}")
+            logger.warning("Failed to log notification: %s", e)
             # Don't fail the notification just because logging failed
             # Don't fail the notification just because logging failed
 
 
     async def _send_to_providers(
     async def _send_to_providers(
@@ -569,11 +569,11 @@ class NotificationService:
                     printer_name=printer_name,
                     printer_name=printer_name,
                 )
                 )
                 if success:
                 if success:
-                    logger.info(f"Sent notification via {provider.name}")
+                    logger.info("Sent notification via %s", provider.name)
                 else:
                 else:
-                    logger.warning(f"Failed to send notification via {provider.name}: {error}")
+                    logger.warning("Failed to send notification via %s: %s", provider.name, error)
             except Exception as e:
             except Exception as e:
-                logger.exception(f"Error sending notification via {provider.name}")
+                logger.exception("Error sending notification via %s", provider.name)
                 await self._update_provider_status(db, provider.id, False, str(e))
                 await self._update_provider_status(db, provider.id, False, str(e))
                 await self._log_notification(
                 await self._log_notification(
                     db=db,
                     db=db,
@@ -604,10 +604,10 @@ class NotificationService:
             db: Database session
             db: Database session
             archive_data: Optional archive data with print_time_seconds from 3MF parsing
             archive_data: Optional archive data with print_time_seconds from 3MF parsing
         """
         """
-        logger.info(f"on_print_start called for printer {printer_id} ({printer_name})")
+        logger.info("on_print_start called for printer %s (%s)", printer_id, printer_name)
         providers = await self._get_providers_for_event(db, "on_print_start", printer_id)
         providers = await self._get_providers_for_event(db, "on_print_start", printer_id)
         if not providers:
         if not providers:
-            logger.info(f"No notification providers configured for print_start event on printer {printer_id}")
+            logger.info("No notification providers configured for print_start event on printer %s", printer_id)
             return
             return
 
 
         # Use subtask_name (project name) if available, otherwise use filename
         # Use subtask_name (project name) if available, otherwise use filename
@@ -627,20 +627,20 @@ class NotificationService:
         # Try archive data first (from 3MF parsing - most reliable)
         # Try archive data first (from 3MF parsing - most reliable)
         if archive_data and archive_data.get("print_time_seconds"):
         if archive_data and archive_data.get("print_time_seconds"):
             estimated_time = archive_data["print_time_seconds"]
             estimated_time = archive_data["print_time_seconds"]
-            logger.debug(f"Using print_time_seconds from archive: {estimated_time}")
+            logger.debug("Using print_time_seconds from archive: %s", estimated_time)
 
 
         # Fall back to MQTT remaining_time
         # Fall back to MQTT remaining_time
         if estimated_time is None:
         if estimated_time is None:
             estimated_time = data.get("remaining_time")
             estimated_time = data.get("remaining_time")
             if estimated_time:
             if estimated_time:
-                logger.debug(f"Using remaining_time from MQTT: {estimated_time}")
+                logger.debug("Using remaining_time from MQTT: %s", estimated_time)
 
 
         # Last resort: raw_data mc_remaining_time (in minutes, convert to seconds)
         # Last resort: raw_data mc_remaining_time (in minutes, convert to seconds)
         if estimated_time is None:
         if estimated_time is None:
             raw_time = data.get("raw_data", {}).get("mc_remaining_time")
             raw_time = data.get("raw_data", {}).get("mc_remaining_time")
             if raw_time:
             if raw_time:
                 estimated_time = raw_time * 60
                 estimated_time = raw_time * 60
-                logger.debug(f"Using mc_remaining_time from raw_data: {estimated_time}")
+                logger.debug("Using mc_remaining_time from raw_data: %s", estimated_time)
 
 
         time_str = self._format_duration(estimated_time)
         time_str = self._format_duration(estimated_time)
 
 
@@ -655,7 +655,7 @@ class NotificationService:
         if archive_data:
         if archive_data:
             image_data = archive_data.get("image_data")
             image_data = archive_data.get("image_data")
 
 
-        logger.info(f"Found {len(providers)} providers for print_start: {[p.name for p in providers]}")
+        logger.info("Found %s providers for print_start: %s", len(providers), [p.name for p in providers])
         title, message = await self._build_message_from_template(db, "print_start", variables)
         title, message = await self._build_message_from_template(db, "print_start", variables)
         await self._send_to_providers(
         await self._send_to_providers(
             providers, title, message, db, "print_start", printer_id, printer_name, image_data=image_data
             providers, title, message, db, "print_start", printer_id, printer_name, image_data=image_data
@@ -671,7 +671,7 @@ class NotificationService:
         archive_data: dict | None = None,
         archive_data: dict | None = None,
     ):
     ):
         """Handle print complete event - send notifications to relevant providers."""
         """Handle print complete event - send notifications to relevant providers."""
-        logger.info(f"on_print_complete called for printer {printer_id} ({printer_name}), status={status}")
+        logger.info("on_print_complete called for printer %s (%s), status=%s", printer_id, printer_name, status)
 
 
         # Determine event type based on status
         # Determine event type based on status
         if status == "completed":
         if status == "completed":
@@ -684,13 +684,13 @@ class NotificationService:
             event_field = "on_print_stopped"
             event_field = "on_print_stopped"
             event_type = "print_stopped"
             event_type = "print_stopped"
         else:
         else:
-            logger.warning(f"Unknown print status '{status}', defaulting to on_print_complete")
+            logger.warning("Unknown print status '%s', defaulting to on_print_complete", status)
             event_field = "on_print_complete"
             event_field = "on_print_complete"
             event_type = "print_complete"
             event_type = "print_complete"
 
 
         providers = await self._get_providers_for_event(db, event_field, printer_id)
         providers = await self._get_providers_for_event(db, event_field, printer_id)
         if not providers:
         if not providers:
-            logger.info(f"No notification providers configured for {event_field} event on printer {printer_id}")
+            logger.info("No notification providers configured for %s event on printer %s", event_field, printer_id)
             return
             return
 
 
         # Use subtask_name (project name) if available, otherwise use filename
         # Use subtask_name (project name) if available, otherwise use filename
@@ -723,7 +723,7 @@ class NotificationService:
         if archive_data:
         if archive_data:
             image_data = archive_data.get("image_data")
             image_data = archive_data.get("image_data")
 
 
-        logger.info(f"Found {len(providers)} providers for {event_field}: {[p.name for p in providers]}")
+        logger.info("Found %s providers for %s: %s", len(providers), event_field, [p.name for p in providers])
         title, message = await self._build_message_from_template(db, event_type, variables)
         title, message = await self._build_message_from_template(db, event_type, variables)
         await self._send_to_providers(
         await self._send_to_providers(
             providers, title, message, db, event_type, printer_id, printer_name, image_data=image_data
             providers, title, message, db, event_type, printer_id, printer_name, image_data=image_data
@@ -851,7 +851,7 @@ class NotificationService:
 
 
         providers = await self._get_providers_for_event(db, "on_maintenance_due", printer_id)
         providers = await self._get_providers_for_event(db, "on_maintenance_due", printer_id)
         if not providers:
         if not providers:
-            logger.info(f"No notification providers configured for maintenance_due event on printer {printer_id}")
+            logger.info("No notification providers configured for maintenance_due event on printer %s", printer_id)
             return
             return
 
 
         # Format maintenance items list
         # Format maintenance items list
@@ -866,7 +866,7 @@ class NotificationService:
             "items": items_str,
             "items": items_str,
         }
         }
 
 
-        logger.info(f"Found {len(providers)} providers for maintenance_due: {[p.name for p in providers]}")
+        logger.info("Found %s providers for maintenance_due: %s", len(providers), [p.name for p in providers])
         title, message = await self._build_message_from_template(db, "maintenance_due", variables)
         title, message = await self._build_message_from_template(db, "maintenance_due", variables)
         await self._send_to_providers(providers, title, message, db, "maintenance_due", printer_id, printer_name)
         await self._send_to_providers(providers, title, message, db, "maintenance_due", printer_id, printer_name)
 
 
@@ -1156,9 +1156,9 @@ class NotificationService:
             )
             )
             db.add(queue_entry)
             db.add(queue_entry)
             await db.commit()
             await db.commit()
-            logger.info(f"Queued notification for digest: {event_type} for provider {provider.name}")
+            logger.info("Queued notification for digest: %s for provider %s", event_type, provider.name)
         except Exception as e:
         except Exception as e:
-            logger.warning(f"Failed to queue notification for digest: {e}")
+            logger.warning("Failed to queue notification for digest: %s", e)
 
 
     async def send_digest(self, provider_id: int):
     async def send_digest(self, provider_id: int):
         """Send all queued notifications as a single digest for a provider."""
         """Send all queued notifications as a single digest for a provider."""
@@ -1181,7 +1181,7 @@ class NotificationService:
             queue_entries = list(result.scalars().all())
             queue_entries = list(result.scalars().all())
 
 
             if not queue_entries:
             if not queue_entries:
-                logger.debug(f"No queued notifications for provider {provider.name}")
+                logger.debug("No queued notifications for provider %s", provider.name)
                 return
                 return
 
 
             # Build digest message
             # Build digest message
@@ -1227,9 +1227,9 @@ class NotificationService:
             await db.commit()
             await db.commit()
 
 
             if success:
             if success:
-                logger.info(f"Sent daily digest with {len(queue_entries)} events to {provider.name}")
+                logger.info("Sent daily digest with %s events to %s", len(queue_entries), provider.name)
             else:
             else:
-                logger.warning(f"Failed to send daily digest to {provider.name}: {error}")
+                logger.warning("Failed to send daily digest to %s: %s", provider.name, error)
 
 
     async def check_and_send_digests(self):
     async def check_and_send_digests(self):
         """Check all providers and send digests if it's their scheduled time."""
         """Check all providers and send digests if it's their scheduled time."""
@@ -1257,7 +1257,7 @@ class NotificationService:
                 try:
                 try:
                     await self.send_digest(provider.id)
                     await self.send_digest(provider.id)
                 except Exception as e:
                 except Exception as e:
-                    logger.error(f"Error sending digest for provider {provider.id}: {e}")
+                    logger.error("Error sending digest for provider %s: %s", provider.id, e)
 
 
     def start_digest_scheduler(self):
     def start_digest_scheduler(self):
         """Start the background scheduler for daily digest notifications."""
         """Start the background scheduler for daily digest notifications."""
@@ -1278,7 +1278,7 @@ class NotificationService:
             try:
             try:
                 await self.check_and_send_digests()
                 await self.check_and_send_digests()
             except Exception as e:
             except Exception as e:
-                logger.error(f"Error in digest scheduler: {e}")
+                logger.error("Error in digest scheduler: %s", e)
 
 
             # Wait until the next minute
             # Wait until the next minute
             await asyncio.sleep(60)
             await asyncio.sleep(60)

+ 15 - 15
backend/app/services/plate_detection.py

@@ -111,7 +111,7 @@ class PlateDetector:
             try:
             try:
                 with open(meta_path) as f:
                 with open(meta_path) as f:
                     return json.load(f)
                     return json.load(f)
-            except Exception:
+            except (json.JSONDecodeError, OSError, KeyError, ValueError):
                 pass
                 pass
         return {"references": {}}
         return {"references": {}}
 
 
@@ -149,7 +149,7 @@ class PlateDetector:
         # Delete slot 0
         # Delete slot 0
         slot0 = _get_calibration_dir() / f"printer_{printer_id}_ref_0.jpg"
         slot0 = _get_calibration_dir() / f"printer_{printer_id}_ref_0.jpg"
         if slot0.exists():
         if slot0.exists():
-            logger.info(f"Rotating references: removing oldest {slot0}")
+            logger.info("Rotating references: removing oldest %s", slot0)
             slot0.unlink()
             slot0.unlink()
         # Shift others down
         # Shift others down
         for i in range(1, self.MAX_REFERENCES):
         for i in range(1, self.MAX_REFERENCES):
@@ -222,7 +222,7 @@ class PlateDetector:
             return False
             return False
 
 
         # Delete image
         # Delete image
-        logger.info(f"Deleting reference {index} for printer {printer_id}: {path}")
+        logger.info("Deleting reference %s for printer %s: %s", index, printer_id, path)
         path.unlink()
         path.unlink()
 
 
         # Remove from metadata
         # Remove from metadata
@@ -275,7 +275,7 @@ class PlateDetector:
             _, buffer = cv2.imencode(".jpg", thumb, [cv2.IMWRITE_JPEG_QUALITY, 80])
             _, buffer = cv2.imencode(".jpg", thumb, [cv2.IMWRITE_JPEG_QUALITY, 80])
             return buffer.tobytes()
             return buffer.tobytes()
         except Exception as e:
         except Exception as e:
-            logger.error(f"Error creating thumbnail: {e}")
+            logger.error("Error creating thumbnail: %s", e)
             return None
             return None
 
 
     def _extract_roi(self, frame: np.ndarray) -> tuple[np.ndarray, int, int, int, int]:
     def _extract_roi(self, frame: np.ndarray) -> tuple[np.ndarray, int, int, int, int]:
@@ -345,21 +345,21 @@ class PlateDetector:
             write_success = cv2.imwrite(str(reference_path), frame, [cv2.IMWRITE_JPEG_QUALITY, 95])
             write_success = cv2.imwrite(str(reference_path), frame, [cv2.IMWRITE_JPEG_QUALITY, 95])
 
 
             if not write_success:
             if not write_success:
-                logger.error(f"cv2.imwrite failed for {reference_path}")
+                logger.error("cv2.imwrite failed for %s", reference_path)
                 return False, "Failed to save reference image", -1
                 return False, "Failed to save reference image", -1
 
 
             # Verify the file actually exists and has content
             # Verify the file actually exists and has content
             if not reference_path.exists():
             if not reference_path.exists():
-                logger.error(f"Reference image not found after save: {reference_path}")
+                logger.error("Reference image not found after save: %s", reference_path)
                 return False, "Reference image not found after save", -1
                 return False, "Reference image not found after save", -1
 
 
             file_size = reference_path.stat().st_size
             file_size = reference_path.stat().st_size
             if file_size < 1000:  # JPEG should be at least 1KB
             if file_size < 1000:  # JPEG should be at least 1KB
-                logger.error(f"Reference image too small ({file_size} bytes): {reference_path}")
+                logger.error("Reference image too small (%s bytes): %s", file_size, reference_path)
                 reference_path.unlink()  # Clean up invalid file
                 reference_path.unlink()  # Clean up invalid file
                 return False, f"Reference image corrupted (only {file_size} bytes)", -1
                 return False, f"Reference image corrupted (only {file_size} bytes)", -1
 
 
-            logger.info(f"Saved reference image: {reference_path} ({file_size} bytes)")
+            logger.info("Saved reference image: %s (%s bytes)", reference_path, file_size)
 
 
             # Save metadata
             # Save metadata
             metadata = self._load_metadata(printer_id)
             metadata = self._load_metadata(printer_id)
@@ -397,7 +397,7 @@ class PlateDetector:
             return False
             return False
         for path in paths:
         for path in paths:
             path.unlink()
             path.unlink()
-        logger.info(f"Deleted {len(paths)} plate calibration reference(s) for printer {printer_id}")
+        logger.info("Deleted %s plate calibration reference(s) for printer %s", len(paths), printer_id)
         return True
         return True
 
 
     def analyze_frame(
     def analyze_frame(
@@ -607,9 +607,9 @@ async def capture_camera_image(
             image_data = await capture_frame(external_camera_url, external_camera_type)
             image_data = await capture_frame(external_camera_url, external_camera_type)
             if image_data:
             if image_data:
                 camera_source = "external"
                 camera_source = "external"
-                logger.debug(f"Captured frame from external camera for printer {printer_id}")
+                logger.debug("Captured frame from external camera for printer %s", printer_id)
         except Exception as e:
         except Exception as e:
-            logger.warning(f"Failed to capture from external camera: {e}")
+            logger.warning("Failed to capture from external camera: %s", e)
 
 
     # Fall back to built-in camera
     # Fall back to built-in camera
     if image_data is None:
     if image_data is None:
@@ -622,9 +622,9 @@ async def capture_camera_image(
             if buffered:
             if buffered:
                 image_data = buffered
                 image_data = buffered
                 camera_source = "built-in (buffered)"
                 camera_source = "built-in (buffered)"
-                logger.debug(f"Using buffered frame from active stream for printer {printer_id}")
+                logger.debug("Using buffered frame from active stream for printer %s", printer_id)
         except Exception as e:
         except Exception as e:
-            logger.debug(f"Could not get buffered frame: {e}")
+            logger.debug("Could not get buffered frame: %s", e)
 
 
         # If no buffered frame, try to capture a new one
         # If no buffered frame, try to capture a new one
         if image_data is None:
         if image_data is None:
@@ -641,11 +641,11 @@ async def capture_camera_image(
                     with open(tmp_path, "rb") as f:
                     with open(tmp_path, "rb") as f:
                         image_data = f.read()
                         image_data = f.read()
                     camera_source = "built-in"
                     camera_source = "built-in"
-                    logger.debug(f"Captured frame from built-in camera for printer {printer_id}")
+                    logger.debug("Captured frame from built-in camera for printer %s", printer_id)
             finally:
             finally:
                 try:
                 try:
                     tmp_path.unlink()
                     tmp_path.unlink()
-                except Exception:
+                except OSError:
                     pass
                     pass
 
 
     return image_data, camera_source
     return image_data, camera_source

+ 37 - 37
backend/app/services/print_scheduler.py

@@ -45,7 +45,7 @@ class PrintScheduler:
             try:
             try:
                 await self.check_queue()
                 await self.check_queue()
             except Exception as e:
             except Exception as e:
-                logger.error(f"Scheduler error: {e}")
+                logger.error("Scheduler error: %s", e)
 
 
             await asyncio.sleep(self._check_interval)
             await asyncio.sleep(self._check_interval)
 
 
@@ -93,13 +93,13 @@ class PrintScheduler:
                     if not printer_connected:
                     if not printer_connected:
                         plug = await self._get_smart_plug(db, item.printer_id)
                         plug = await self._get_smart_plug(db, item.printer_id)
                         if plug and plug.auto_on and plug.enabled:
                         if plug and plug.auto_on and plug.enabled:
-                            logger.info(f"Printer {item.printer_id} offline, attempting to power on via smart plug")
+                            logger.info("Printer %s offline, attempting to power on via smart plug", item.printer_id)
                             powered_on = await self._power_on_and_wait(plug, item.printer_id, db)
                             powered_on = await self._power_on_and_wait(plug, item.printer_id, db)
                             if powered_on:
                             if powered_on:
                                 printer_connected = True
                                 printer_connected = True
                                 printer_idle = self._is_printer_idle(item.printer_id)
                                 printer_idle = self._is_printer_idle(item.printer_id)
                             else:
                             else:
-                                logger.warning(f"Could not power on printer {item.printer_id} via smart plug")
+                                logger.warning("Could not power on printer %s via smart plug", item.printer_id)
                                 busy_printers.add(item.printer_id)
                                 busy_printers.add(item.printer_id)
                                 continue
                                 continue
                         else:
                         else:
@@ -119,7 +119,7 @@ class PrintScheduler:
                             item.error_message = "Previous print failed or was aborted"
                             item.error_message = "Previous print failed or was aborted"
                             item.completed_at = datetime.now()
                             item.completed_at = datetime.now()
                             await db.commit()
                             await db.commit()
-                            logger.info(f"Skipped queue item {item.id} - previous print failed")
+                            logger.info("Skipped queue item %s - previous print failed", item.id)
 
 
                             # Send notification
                             # Send notification
                             job_name = await self._get_job_name(db, item)
                             job_name = await self._get_job_name(db, item)
@@ -175,7 +175,7 @@ class PrintScheduler:
                                 item.error_message = "Previous print failed or was aborted"
                                 item.error_message = "Previous print failed or was aborted"
                                 item.completed_at = datetime.now()
                                 item.completed_at = datetime.now()
                                 await db.commit()
                                 await db.commit()
-                                logger.info(f"Skipped queue item {item.id} - previous print failed")
+                                logger.info("Skipped queue item %s - previous print failed", item.id)
 
 
                                 # Send notification
                                 # Send notification
                                 job_name = await self._get_job_name(db, item)
                                 job_name = await self._get_job_name(db, item)
@@ -192,7 +192,7 @@ class PrintScheduler:
                         # Assign printer and start - clear waiting reason
                         # Assign printer and start - clear waiting reason
                         item.printer_id = printer_id
                         item.printer_id = printer_id
                         item.waiting_reason = None
                         item.waiting_reason = None
-                        logger.info(f"Model-based assignment: queue item {item.id} assigned to printer {printer_id}")
+                        logger.info("Model-based assignment: queue item %s assigned to printer %s", item.id, printer_id)
 
 
                         # Send assignment notification
                         # Send assignment notification
                         job_name = await self._get_job_name(db, item)
                         job_name = await self._get_job_name(db, item)
@@ -287,7 +287,7 @@ class PrintScheduler:
                 missing = self._get_missing_filament_types(printer.id, required_filament_types)
                 missing = self._get_missing_filament_types(printer.id, required_filament_types)
                 if missing:
                 if missing:
                     printers_missing_filament.append((printer.name, missing))
                     printers_missing_filament.append((printer.name, missing))
-                    logger.debug(f"Skipping printer {printer.id} ({printer.name}) - missing filaments: {missing}")
+                    logger.debug("Skipping printer %s (%s) - missing filaments: %s", printer.id, printer.name, missing)
                     continue
                     continue
 
 
             # Found a matching printer - clear waiting reason
             # Found a matching printer - clear waiting reason
@@ -366,19 +366,19 @@ class PrintScheduler:
         # Get printer status
         # Get printer status
         status = printer_manager.get_status(printer_id)
         status = printer_manager.get_status(printer_id)
         if not status:
         if not status:
-            logger.warning(f"Cannot compute AMS mapping: printer {printer_id} status unavailable")
+            logger.warning("Cannot compute AMS mapping: printer %s status unavailable", printer_id)
             return None
             return None
 
 
         # Get filament requirements from source file
         # Get filament requirements from source file
         filament_reqs = await self._get_filament_requirements(db, item)
         filament_reqs = await self._get_filament_requirements(db, item)
         if not filament_reqs:
         if not filament_reqs:
-            logger.debug(f"No filament requirements found for queue item {item.id}")
+            logger.debug("No filament requirements found for queue item %s", item.id)
             return None
             return None
 
 
         # Build loaded filaments from printer status
         # Build loaded filaments from printer status
         loaded_filaments = self._build_loaded_filaments(status)
         loaded_filaments = self._build_loaded_filaments(status)
         if not loaded_filaments:
         if not loaded_filaments:
-            logger.debug(f"No filaments loaded on printer {printer_id}")
+            logger.debug("No filaments loaded on printer %s", printer_id)
             return None
             return None
 
 
         # Compute mapping: match required filaments to available slots
         # Compute mapping: match required filaments to available slots
@@ -478,7 +478,7 @@ class PrintScheduler:
 
 
                 filaments.sort(key=lambda x: x["slot_id"])
                 filaments.sort(key=lambda x: x["slot_id"])
         except Exception as e:
         except Exception as e:
-            logger.warning(f"Failed to parse filament requirements: {e}")
+            logger.warning("Failed to parse filament requirements: %s", e)
             return None
             return None
 
 
         return filaments if filaments else None
         return filaments if filaments else None
@@ -713,48 +713,48 @@ class PrintScheduler:
         # Check current plug state
         # Check current plug state
         status = await service.get_status(plug)
         status = await service.get_status(plug)
         if not status.get("reachable"):
         if not status.get("reachable"):
-            logger.warning(f"Smart plug '{plug.name}' is not reachable")
+            logger.warning("Smart plug '%s' is not reachable", plug.name)
             return False
             return False
 
 
         # Turn on if not already on
         # Turn on if not already on
         if status.get("state") != "ON":
         if status.get("state") != "ON":
             success = await service.turn_on(plug)
             success = await service.turn_on(plug)
             if not success:
             if not success:
-                logger.warning(f"Failed to turn on smart plug '{plug.name}'")
+                logger.warning("Failed to turn on smart plug '%s'", plug.name)
                 return False
                 return False
-            logger.info(f"Powered on smart plug '{plug.name}' for printer {printer_id}")
+            logger.info("Powered on smart plug '%s' for printer %s", plug.name, printer_id)
 
 
         # Get printer from database for connection
         # Get printer from database for connection
         result = await db.execute(select(Printer).where(Printer.id == printer_id))
         result = await db.execute(select(Printer).where(Printer.id == printer_id))
         printer = result.scalar_one_or_none()
         printer = result.scalar_one_or_none()
         if not printer:
         if not printer:
-            logger.error(f"Printer {printer_id} not found in database")
+            logger.error("Printer %s not found in database", printer_id)
             return False
             return False
 
 
         # Wait for printer to boot (give it some time before trying to connect)
         # Wait for printer to boot (give it some time before trying to connect)
-        logger.info(f"Waiting 30s for printer {printer_id} to boot...")
+        logger.info("Waiting 30s for printer %s to boot...", printer_id)
         await asyncio.sleep(30)
         await asyncio.sleep(30)
 
 
         # Try to connect to the printer periodically
         # Try to connect to the printer periodically
         elapsed = 30  # Already waited 30s
         elapsed = 30  # Already waited 30s
         while elapsed < self._power_on_wait_time:
         while elapsed < self._power_on_wait_time:
             # Try to connect
             # Try to connect
-            logger.info(f"Attempting to connect to printer {printer_id}...")
+            logger.info("Attempting to connect to printer %s...", printer_id)
             try:
             try:
                 connected = await printer_manager.connect_printer(printer)
                 connected = await printer_manager.connect_printer(printer)
                 if connected:
                 if connected:
-                    logger.info(f"Printer {printer_id} connected after {elapsed}s")
+                    logger.info("Printer %s connected after %ss", printer_id, elapsed)
                     # Give it a moment to stabilize and get status
                     # Give it a moment to stabilize and get status
                     await asyncio.sleep(5)
                     await asyncio.sleep(5)
                     return True
                     return True
             except Exception as e:
             except Exception as e:
-                logger.debug(f"Connection attempt failed: {e}")
+                logger.debug("Connection attempt failed: %s", e)
 
 
             await asyncio.sleep(self._power_on_check_interval)
             await asyncio.sleep(self._power_on_check_interval)
             elapsed += self._power_on_check_interval
             elapsed += self._power_on_check_interval
-            logger.debug(f"Waiting for printer {printer_id} to connect... ({elapsed}s)")
+            logger.debug("Waiting for printer %s to connect... (%ss)", printer_id, elapsed)
 
 
-        logger.warning(f"Printer {printer_id} did not connect within {self._power_on_wait_time}s after power on")
+        logger.warning("Printer %s did not connect within %ss after power on", printer_id, self._power_on_wait_time)
         return False
         return False
 
 
     async def _check_previous_success(self, db: AsyncSession, item: PrintQueueItem) -> bool:
     async def _check_previous_success(self, db: AsyncSession, item: PrintQueueItem) -> bool:
@@ -783,10 +783,10 @@ class PrintScheduler:
 
 
         plug = await self._get_smart_plug(db, item.printer_id)
         plug = await self._get_smart_plug(db, item.printer_id)
         if plug and plug.enabled:
         if plug and plug.enabled:
-            logger.info(f"Auto-off: Waiting for printer {item.printer_id} to cool down before power off...")
+            logger.info("Auto-off: Waiting for printer %s to cool down before power off...", item.printer_id)
             # Wait for cooldown (up to 10 minutes)
             # Wait for cooldown (up to 10 minutes)
             await printer_manager.wait_for_cooldown(item.printer_id, target_temp=50.0, timeout=600)
             await printer_manager.wait_for_cooldown(item.printer_id, target_temp=50.0, timeout=600)
-            logger.info(f"Auto-off: Powering off printer {item.printer_id}")
+            logger.info("Auto-off: Powering off printer %s", item.printer_id)
             service = await smart_plug_manager.get_service_for_plug(plug, db)
             service = await smart_plug_manager.get_service_for_plug(plug, db)
             await service.turn_off(plug)
             await service.turn_off(plug)
 
 
@@ -816,7 +816,7 @@ class PrintScheduler:
         - archive_id: Print from an existing archive
         - archive_id: Print from an existing archive
         - library_file_id: Print from a library file (file manager)
         - library_file_id: Print from a library file (file manager)
         """
         """
-        logger.info(f"Starting queue item {item.id}")
+        logger.info("Starting queue item %s", item.id)
 
 
         # Get printer first (needed for both paths)
         # Get printer first (needed for both paths)
         result = await db.execute(select(Printer).where(Printer.id == item.printer_id))
         result = await db.execute(select(Printer).where(Printer.id == item.printer_id))
@@ -826,7 +826,7 @@ class PrintScheduler:
             item.error_message = "Printer not found"
             item.error_message = "Printer not found"
             item.completed_at = datetime.utcnow()
             item.completed_at = datetime.utcnow()
             await db.commit()
             await db.commit()
-            logger.error(f"Queue item {item.id}: Printer {item.printer_id} not found")
+            logger.error("Queue item %s: Printer %s not found", item.id, item.printer_id)
             await self._power_off_if_needed(db, item)
             await self._power_off_if_needed(db, item)
             return
             return
 
 
@@ -836,7 +836,7 @@ class PrintScheduler:
             item.error_message = "Printer not connected"
             item.error_message = "Printer not connected"
             item.completed_at = datetime.utcnow()
             item.completed_at = datetime.utcnow()
             await db.commit()
             await db.commit()
-            logger.error(f"Queue item {item.id}: Printer {item.printer_id} not connected")
+            logger.error("Queue item %s: Printer %s not connected", item.id, item.printer_id)
             await self._power_off_if_needed(db, item)
             await self._power_off_if_needed(db, item)
             return
             return
 
 
@@ -855,7 +855,7 @@ class PrintScheduler:
                 item.error_message = "Archive not found"
                 item.error_message = "Archive not found"
                 item.completed_at = datetime.utcnow()
                 item.completed_at = datetime.utcnow()
                 await db.commit()
                 await db.commit()
-                logger.error(f"Queue item {item.id}: Archive {item.archive_id} not found")
+                logger.error("Queue item %s: Archive %s not found", item.id, item.archive_id)
                 await self._power_off_if_needed(db, item)
                 await self._power_off_if_needed(db, item)
                 return
                 return
 
 
@@ -892,7 +892,7 @@ class PrintScheduler:
                 item.error_message = "Library file not found"
                 item.error_message = "Library file not found"
                 item.completed_at = datetime.utcnow()
                 item.completed_at = datetime.utcnow()
                 await db.commit()
                 await db.commit()
-                logger.error(f"Queue item {item.id}: Library file {item.library_file_id} not found")
+                logger.error("Queue item %s: Library file %s not found", item.id, item.library_file_id)
                 await self._power_off_if_needed(db, item)
                 await self._power_off_if_needed(db, item)
                 return
                 return
             # Library files store absolute paths
             # Library files store absolute paths
@@ -908,7 +908,7 @@ class PrintScheduler:
             item.error_message = "No source file specified"
             item.error_message = "No source file specified"
             item.completed_at = datetime.utcnow()
             item.completed_at = datetime.utcnow()
             await db.commit()
             await db.commit()
-            logger.error(f"Queue item {item.id}: No archive_id or library_file_id specified")
+            logger.error("Queue item %s: No archive_id or library_file_id specified", item.id)
             await self._power_off_if_needed(db, item)
             await self._power_off_if_needed(db, item)
             return
             return
 
 
@@ -918,7 +918,7 @@ class PrintScheduler:
             item.error_message = "Source file not found on disk"
             item.error_message = "Source file not found on disk"
             item.completed_at = datetime.utcnow()
             item.completed_at = datetime.utcnow()
             await db.commit()
             await db.commit()
-            logger.error(f"Queue item {item.id}: File not found: {file_path}")
+            logger.error("Queue item %s: File not found: %s", item.id, file_path)
             await self._power_off_if_needed(db, item)
             await self._power_off_if_needed(db, item)
             return
             return
 
 
@@ -945,7 +945,7 @@ class PrintScheduler:
 
 
         # Delete existing file if present (avoids 553 error on overwrite)
         # Delete existing file if present (avoids 553 error on overwrite)
         try:
         try:
-            logger.debug(f"Queue item {item.id}: Deleting existing file {remote_path} if present...")
+            logger.debug("Queue item %s: Deleting existing file %s if present...", item.id, remote_path)
             delete_result = await delete_file_async(
             delete_result = await delete_file_async(
                 printer.ip_address,
                 printer.ip_address,
                 printer.access_code,
                 printer.access_code,
@@ -953,9 +953,9 @@ class PrintScheduler:
                 socket_timeout=ftp_timeout,
                 socket_timeout=ftp_timeout,
                 printer_model=printer.model,
                 printer_model=printer.model,
             )
             )
-            logger.debug(f"Queue item {item.id}: Delete result: {delete_result}")
+            logger.debug("Queue item %s: Delete result: %s", item.id, delete_result)
         except Exception as e:
         except Exception as e:
-            logger.debug(f"Queue item {item.id}: Delete failed (may not exist): {e}")
+            logger.debug("Queue item %s: Delete failed (may not exist): %s", item.id, e)
 
 
         try:
         try:
             if ftp_retry_enabled:
             if ftp_retry_enabled:
@@ -982,7 +982,7 @@ class PrintScheduler:
                 )
                 )
         except Exception as e:
         except Exception as e:
             uploaded = False
             uploaded = False
-            logger.error(f"Queue item {item.id}: FTP error: {e} (type: {type(e).__name__})")
+            logger.error("Queue item %s: FTP error: %s (type: %s)", item.id, e, type(e).__name__)
 
 
         if not uploaded:
         if not uploaded:
             error_msg = (
             error_msg = (
@@ -1022,7 +1022,7 @@ class PrintScheduler:
             try:
             try:
                 ams_mapping = json.loads(item.ams_mapping)
                 ams_mapping = json.loads(item.ams_mapping)
             except json.JSONDecodeError:
             except json.JSONDecodeError:
-                logger.warning(f"Queue item {item.id}: Invalid AMS mapping JSON, ignoring")
+                logger.warning("Queue item %s: Invalid AMS mapping JSON, ignoring", item.id)
 
 
         # IMPORTANT: Set status to "printing" BEFORE sending the print command.
         # IMPORTANT: Set status to "printing" BEFORE sending the print command.
         # This prevents phantom reprints if the backend crashes/restarts after the
         # This prevents phantom reprints if the backend crashes/restarts after the
@@ -1033,7 +1033,7 @@ class PrintScheduler:
         item.status = "printing"
         item.status = "printing"
         item.started_at = datetime.utcnow()
         item.started_at = datetime.utcnow()
         await db.commit()
         await db.commit()
-        logger.info(f"Queue item {item.id}: Status set to 'printing', sending print command...")
+        logger.info("Queue item %s: Status set to 'printing', sending print command...", item.id)
 
 
         # Start the print with AMS mapping, plate_id and print options
         # Start the print with AMS mapping, plate_id and print options
         started = printer_manager.start_print(
         started = printer_manager.start_print(
@@ -1050,7 +1050,7 @@ class PrintScheduler:
         )
         )
 
 
         if started:
         if started:
-            logger.info(f"Queue item {item.id}: Print started successfully - {filename}")
+            logger.info("Queue item %s: Print started successfully - %s", item.id, filename)
 
 
             # Get estimated time for notification
             # Get estimated time for notification
             estimated_time = None
             estimated_time = None

+ 5 - 5
backend/app/services/printer_manager.py

@@ -269,7 +269,7 @@ class PrinterManager:
         if printer_id in self._clients:
         if printer_id in self._clients:
             client = self._clients[printer_id]
             client = self._clients[printer_id]
             if client.state.connected:
             if client.state.connected:
-                logger.info(f"Marking printer {printer_id} as offline (smart plug power off)")
+                logger.info("Marking printer %s as offline (smart plug power off)", printer_id)
                 client.state.connected = False
                 client.state.connected = False
                 client.state.state = "unknown"
                 client.state.state = "unknown"
                 # Trigger the status change callback to broadcast via WebSocket
                 # Trigger the status change callback to broadcast via WebSocket
@@ -336,7 +336,7 @@ class PrinterManager:
         while elapsed < timeout:
         while elapsed < timeout:
             state = self.get_status(printer_id)
             state = self.get_status(printer_id)
             if not state or not state.connected:
             if not state or not state.connected:
-                logger.warning(f"Printer {printer_id} disconnected during cooldown wait")
+                logger.warning("Printer %s disconnected during cooldown wait", printer_id)
                 return False
                 return False
 
 
             # Check nozzle temperature (and nozzle_2 for dual extruders)
             # Check nozzle temperature (and nozzle_2 for dual extruders)
@@ -345,14 +345,14 @@ class PrinterManager:
             max_temp = max(nozzle_temp, nozzle_2_temp)
             max_temp = max(nozzle_temp, nozzle_2_temp)
 
 
             if max_temp <= target_temp:
             if max_temp <= target_temp:
-                logger.info(f"Printer {printer_id} cooled down to {max_temp}°C")
+                logger.info("Printer %s cooled down to %s°C", printer_id, max_temp)
                 return True
                 return True
 
 
-            logger.debug(f"Printer {printer_id} nozzle at {max_temp}°C, waiting for {target_temp}°C...")
+            logger.debug("Printer %s nozzle at %s°C, waiting for %s°C...", printer_id, max_temp, target_temp)
             await asyncio.sleep(check_interval)
             await asyncio.sleep(check_interval)
             elapsed += check_interval
             elapsed += check_interval
 
 
-        logger.warning(f"Printer {printer_id} cooldown timeout after {timeout}s")
+        logger.warning("Printer %s cooldown timeout after %ss", printer_id, timeout)
         return False
         return False
 
 
     def enable_logging(self, printer_id: int, enabled: bool = True) -> bool:
     def enable_logging(self, printer_id: int, enabled: bool = True) -> bool:

+ 31 - 27
backend/app/services/smart_plug_manager.py

@@ -63,7 +63,7 @@ class SmartPlugManager:
             ha_token = ha_token_setting.value if ha_token_setting else ""
             ha_token = ha_token_setting.value if ha_token_setting else ""
             homeassistant_service.configure(ha_url, ha_token)
             homeassistant_service.configure(ha_url, ha_token)
         except Exception as e:
         except Exception as e:
-            logger.warning(f"Failed to configure HA service: {e}")
+            logger.warning("Failed to configure HA service: %s", e)
 
 
     def set_event_loop(self, loop: asyncio.AbstractEventLoop):
     def set_event_loop(self, loop: asyncio.AbstractEventLoop):
         """Set the event loop for async operations."""
         """Set the event loop for async operations."""
@@ -88,7 +88,7 @@ class SmartPlugManager:
             try:
             try:
                 await self._check_schedules()
                 await self._check_schedules()
             except Exception as e:
             except Exception as e:
-                logger.error(f"Error in schedule check: {e}")
+                logger.error("Error in schedule check: %s", e)
 
 
             # Wait until the next minute
             # Wait until the next minute
             await asyncio.sleep(60)
             await asyncio.sleep(60)
@@ -116,7 +116,7 @@ class SmartPlugManager:
                 if plug.schedule_on_time == current_time:
                 if plug.schedule_on_time == current_time:
                     last_check = self._last_schedule_check.get(plug.id)
                     last_check = self._last_schedule_check.get(plug.id)
                     if last_check != f"on:{current_time}":
                     if last_check != f"on:{current_time}":
-                        logger.info(f"Schedule: Turning on plug '{plug.name}' at {current_time}")
+                        logger.info("Schedule: Turning on plug '%s' at %s", plug.name, current_time)
                         success = await service.turn_on(plug)
                         success = await service.turn_on(plug)
                         if success:
                         if success:
                             plug.last_state = "ON"
                             plug.last_state = "ON"
@@ -127,7 +127,7 @@ class SmartPlugManager:
                 if plug.schedule_off_time == current_time:
                 if plug.schedule_off_time == current_time:
                     last_check = self._last_schedule_check.get(plug.id)
                     last_check = self._last_schedule_check.get(plug.id)
                     if last_check != f"off:{current_time}":
                     if last_check != f"off:{current_time}":
-                        logger.info(f"Schedule: Turning off plug '{plug.name}' at {current_time}")
+                        logger.info("Schedule: Turning off plug '%s' at %s", plug.name, current_time)
                         success = await service.turn_off(plug)
                         success = await service.turn_off(plug)
                         if success:
                         if success:
                             plug.last_state = "OFF"
                             plug.last_state = "OFF"
@@ -154,18 +154,18 @@ class SmartPlugManager:
             return
             return
 
 
         if not plug.enabled:
         if not plug.enabled:
-            logger.debug(f"Smart plug '{plug.name}' is disabled, skipping auto-on")
+            logger.debug("Smart plug '%s' is disabled, skipping auto-on", plug.name)
             return
             return
 
 
         if not plug.auto_on:
         if not plug.auto_on:
-            logger.debug(f"Smart plug '{plug.name}' auto_on is disabled")
+            logger.debug("Smart plug '%s' auto_on is disabled", plug.name)
             return
             return
 
 
         # Cancel any pending off task
         # Cancel any pending off task
         self._cancel_pending_off(plug.id)
         self._cancel_pending_off(plug.id)
 
 
         # Turn on the plug
         # Turn on the plug
-        logger.info(f"Print started on printer {printer_id}, turning on plug '{plug.name}'")
+        logger.info("Print started on printer %s, turning on plug '%s'", printer_id, plug.name)
         service = await self.get_service_for_plug(plug, db)
         service = await self.get_service_for_plug(plug, db)
         success = await service.turn_on(plug)
         success = await service.turn_on(plug)
 
 
@@ -188,16 +188,16 @@ class SmartPlugManager:
             return
             return
 
 
         if not plug.enabled:
         if not plug.enabled:
-            logger.debug(f"Smart plug '{plug.name}' is disabled, skipping auto-off")
+            logger.debug("Smart plug '%s' is disabled, skipping auto-off", plug.name)
             return
             return
 
 
         if not plug.auto_off:
         if not plug.auto_off:
-            logger.debug(f"Smart plug '{plug.name}' auto_off is disabled")
+            logger.debug("Smart plug '%s' auto_off is disabled", plug.name)
             return
             return
 
 
         # Skip auto-off for HA script entities (scripts can only be triggered, not turned off)
         # Skip auto-off for HA script entities (scripts can only be triggered, not turned off)
         if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."):
         if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."):
-            logger.debug(f"Smart plug '{plug.name}' is a HA script entity, skipping auto-off")
+            logger.debug("Smart plug '%s' is a HA script entity, skipping auto-off", plug.name)
             return
             return
 
 
         # Only auto-off on successful completion, not on failures
         # Only auto-off on successful completion, not on failures
@@ -209,7 +209,9 @@ class SmartPlugManager:
             )
             )
             return
             return
 
 
-        logger.info(f"Print completed successfully on printer {printer_id}, scheduling turn-off for plug '{plug.name}'")
+        logger.info(
+            "Print completed successfully on printer %s, scheduling turn-off for plug '%s'", printer_id, plug.name
+        )
 
 
         if plug.off_delay_mode == "time":
         if plug.off_delay_mode == "time":
             self._schedule_delayed_off(plug, printer_id, plug.off_delay_minutes * 60)
             self._schedule_delayed_off(plug, printer_id, plug.off_delay_minutes * 60)
@@ -221,7 +223,7 @@ class SmartPlugManager:
         # Cancel any existing task for this plug
         # Cancel any existing task for this plug
         self._cancel_pending_off(plug.id)
         self._cancel_pending_off(plug.id)
 
 
-        logger.info(f"Scheduling turn-off for plug '{plug.name}' in {delay_seconds} seconds")
+        logger.info("Scheduling turn-off for plug '%s' in %s seconds", plug.name, delay_seconds)
 
 
         # Mark as pending in database (survives restarts)
         # Mark as pending in database (survives restarts)
         asyncio.create_task(self._mark_auto_off_pending(plug.id, True))
         asyncio.create_task(self._mark_auto_off_pending(plug.id, True))
@@ -268,7 +270,7 @@ class SmartPlugManager:
             plug_info = PlugInfo()
             plug_info = PlugInfo()
             service = await self.get_service_for_plug(plug_info)
             service = await self.get_service_for_plug(plug_info)
             success = await service.turn_off(plug_info)
             success = await service.turn_off(plug_info)
-            logger.info(f"Turned off plug {plug_id} after time delay")
+            logger.info("Turned off plug %s after time delay", plug_id)
 
 
             # Mark auto_off_executed in database and update printer status
             # Mark auto_off_executed in database and update printer status
             if success:
             if success:
@@ -277,7 +279,7 @@ class SmartPlugManager:
                 printer_manager.mark_printer_offline(printer_id)
                 printer_manager.mark_printer_offline(printer_id)
 
 
         except asyncio.CancelledError:
         except asyncio.CancelledError:
-            logger.debug(f"Delayed turn-off cancelled for plug {plug_id}")
+            logger.debug("Delayed turn-off cancelled for plug %s", plug_id)
         finally:
         finally:
             self._pending_off.pop(plug_id, None)
             self._pending_off.pop(plug_id, None)
 
 
@@ -286,7 +288,7 @@ class SmartPlugManager:
         # Cancel any existing task for this plug
         # Cancel any existing task for this plug
         self._cancel_pending_off(plug.id)
         self._cancel_pending_off(plug.id)
 
 
-        logger.info(f"Scheduling temperature-based turn-off for plug '{plug.name}' (threshold: {temp_threshold}°C)")
+        logger.info("Scheduling temperature-based turn-off for plug '%s' (threshold: %s°C)", plug.name, temp_threshold)
 
 
         # Mark as pending in database (survives restarts)
         # Mark as pending in database (survives restarts)
         asyncio.create_task(self._mark_auto_off_pending(plug.id, True))
         asyncio.create_task(self._mark_auto_off_pending(plug.id, True))
@@ -344,7 +346,9 @@ class SmartPlugManager:
                             f"threshold={temp_threshold}°C"
                             f"threshold={temp_threshold}°C"
                         )
                         )
                     else:
                     else:
-                        logger.info(f"Temp check plug {plug_id}: nozzle={nozzle_temp}°C, threshold={temp_threshold}°C")
+                        logger.info(
+                            "Temp check plug %s: nozzle=%s°C, threshold=%s°C", plug_id, nozzle_temp, temp_threshold
+                        )
 
 
                     if max_nozzle_temp < temp_threshold:
                     if max_nozzle_temp < temp_threshold:
                         # All nozzles are below threshold, turn off
                         # All nozzles are below threshold, turn off
@@ -377,10 +381,10 @@ class SmartPlugManager:
                 elapsed += check_interval
                 elapsed += check_interval
 
 
             if elapsed >= max_wait:
             if elapsed >= max_wait:
-                logger.warning(f"Temperature-based turn-off timed out for plug {plug_id} after {max_wait}s")
+                logger.warning("Temperature-based turn-off timed out for plug %s after %ss", plug_id, max_wait)
 
 
         except asyncio.CancelledError:
         except asyncio.CancelledError:
-            logger.debug(f"Temperature-based turn-off cancelled for plug {plug_id}")
+            logger.debug("Temperature-based turn-off cancelled for plug %s", plug_id)
         finally:
         finally:
             self._pending_off.pop(plug_id, None)
             self._pending_off.pop(plug_id, None)
 
 
@@ -397,9 +401,9 @@ class SmartPlugManager:
                     plug.auto_off_pending = pending
                     plug.auto_off_pending = pending
                     plug.auto_off_pending_since = datetime.utcnow() if pending else None
                     plug.auto_off_pending_since = datetime.utcnow() if pending else None
                     await db.commit()
                     await db.commit()
-                    logger.debug(f"Marked plug {plug_id} auto_off_pending={pending}")
+                    logger.debug("Marked plug %s auto_off_pending=%s", plug_id, pending)
         except Exception as e:
         except Exception as e:
-            logger.warning(f"Failed to update plug {plug_id} pending state: {e}")
+            logger.warning("Failed to update plug %s pending state: %s", plug_id, e)
 
 
     async def _mark_auto_off_executed(self, plug_id: int):
     async def _mark_auto_off_executed(self, plug_id: int):
         """Disable auto-off after it was executed (one-shot behavior)."""
         """Disable auto-off after it was executed (one-shot behavior)."""
@@ -418,14 +422,14 @@ class SmartPlugManager:
                     plug.last_state = "OFF"
                     plug.last_state = "OFF"
                     plug.last_checked = datetime.utcnow()
                     plug.last_checked = datetime.utcnow()
                     await db.commit()
                     await db.commit()
-                    logger.info(f"Auto-off executed and disabled for plug {plug_id}")
+                    logger.info("Auto-off executed and disabled for plug %s", plug_id)
         except Exception as e:
         except Exception as e:
-            logger.warning(f"Failed to update plug {plug_id} after auto-off: {e}")
+            logger.warning("Failed to update plug %s after auto-off: %s", plug_id, e)
 
 
     def _cancel_pending_off(self, plug_id: int):
     def _cancel_pending_off(self, plug_id: int):
         """Cancel any pending off task for this plug."""
         """Cancel any pending off task for this plug."""
         if plug_id in self._pending_off:
         if plug_id in self._pending_off:
-            logger.debug(f"Cancelling pending turn-off for plug {plug_id}")
+            logger.debug("Cancelling pending turn-off for plug %s", plug_id)
             self._pending_off[plug_id].cancel()
             self._pending_off[plug_id].cancel()
             del self._pending_off[plug_id]
             del self._pending_off[plug_id]
             # Clear pending state in database
             # Clear pending state in database
@@ -470,14 +474,14 @@ class SmartPlugManager:
                             await db.commit()
                             await db.commit()
                             continue
                             continue
 
 
-                    logger.info(f"Resuming pending auto-off for plug '{plug.name}' (printer {plug.printer_id})")
+                    logger.info("Resuming pending auto-off for plug '%s' (printer %s)", plug.name, plug.printer_id)
 
 
                     # Resume the appropriate off mode
                     # Resume the appropriate off mode
                     if plug.off_delay_mode == "temperature":
                     if plug.off_delay_mode == "temperature":
                         self._schedule_temp_based_off(plug, plug.printer_id, plug.off_temp_threshold)
                         self._schedule_temp_based_off(plug, plug.printer_id, plug.off_temp_threshold)
                     else:
                     else:
                         # For time mode, just turn off immediately since delay already passed
                         # For time mode, just turn off immediately since delay already passed
-                        logger.info(f"Time-based auto-off was pending, turning off plug '{plug.name}' now")
+                        logger.info("Time-based auto-off was pending, turning off plug '%s' now", plug.name)
 
 
                         service = await self.get_service_for_plug(plug, db)
                         service = await self.get_service_for_plug(plug, db)
                         success = await service.turn_off(plug)
                         success = await service.turn_off(plug)
@@ -486,10 +490,10 @@ class SmartPlugManager:
                             printer_manager.mark_printer_offline(plug.printer_id)
                             printer_manager.mark_printer_offline(plug.printer_id)
 
 
                 if pending_plugs:
                 if pending_plugs:
-                    logger.info(f"Resumed {len(pending_plugs)} pending auto-off(s)")
+                    logger.info("Resumed %s pending auto-off(s)", len(pending_plugs))
 
 
         except Exception as e:
         except Exception as e:
-            logger.warning(f"Failed to resume pending auto-offs: {e}")
+            logger.warning("Failed to resume pending auto-offs: %s", e)
 
 
 
 
 # Global singleton
 # Global singleton

+ 24 - 24
backend/app/services/spoolman.py

@@ -91,7 +91,7 @@ class SpoolmanClient:
             self._connected = response.status_code == 200
             self._connected = response.status_code == 200
             return self._connected
             return self._connected
         except Exception as e:
         except Exception as e:
-            logger.warning(f"Spoolman health check failed: {e}")
+            logger.warning("Spoolman health check failed: %s", e)
             self._connected = False
             self._connected = False
             return False
             return False
 
 
@@ -112,7 +112,7 @@ class SpoolmanClient:
             response.raise_for_status()
             response.raise_for_status()
             return response.json()
             return response.json()
         except Exception as e:
         except Exception as e:
-            logger.error(f"Failed to get spools from Spoolman: {e}")
+            logger.error("Failed to get spools from Spoolman: %s", e)
             return []
             return []
 
 
     async def get_filaments(self) -> list[dict]:
     async def get_filaments(self) -> list[dict]:
@@ -127,7 +127,7 @@ class SpoolmanClient:
             response.raise_for_status()
             response.raise_for_status()
             return response.json()
             return response.json()
         except Exception as e:
         except Exception as e:
-            logger.error(f"Failed to get filaments from Spoolman: {e}")
+            logger.error("Failed to get filaments from Spoolman: %s", e)
             return []
             return []
 
 
     async def get_external_filaments(self) -> list[dict]:
     async def get_external_filaments(self) -> list[dict]:
@@ -142,7 +142,7 @@ class SpoolmanClient:
             response.raise_for_status()
             response.raise_for_status()
             return response.json()
             return response.json()
         except Exception as e:
         except Exception as e:
-            logger.error(f"Failed to get external filaments from Spoolman: {e}")
+            logger.error("Failed to get external filaments from Spoolman: %s", e)
             return []
             return []
 
 
     async def get_vendors(self) -> list[dict]:
     async def get_vendors(self) -> list[dict]:
@@ -157,7 +157,7 @@ class SpoolmanClient:
             response.raise_for_status()
             response.raise_for_status()
             return response.json()
             return response.json()
         except Exception as e:
         except Exception as e:
-            logger.error(f"Failed to get vendors from Spoolman: {e}")
+            logger.error("Failed to get vendors from Spoolman: %s", e)
             return []
             return []
 
 
     async def create_vendor(self, name: str) -> dict | None:
     async def create_vendor(self, name: str) -> dict | None:
@@ -175,7 +175,7 @@ class SpoolmanClient:
             response.raise_for_status()
             response.raise_for_status()
             return response.json()
             return response.json()
         except Exception as e:
         except Exception as e:
-            logger.error(f"Failed to create vendor in Spoolman: {e}")
+            logger.error("Failed to create vendor in Spoolman: %s", e)
             return None
             return None
 
 
     def _get_material_density(self, material: str | None) -> float:
     def _get_material_density(self, material: str | None) -> float:
@@ -262,16 +262,16 @@ class SpoolmanClient:
             if weight:
             if weight:
                 data["weight"] = weight
                 data["weight"] = weight
 
 
-            logger.debug(f"Creating filament in Spoolman: {data}")
+            logger.debug("Creating filament in Spoolman: %s", data)
             client = await self._get_client()
             client = await self._get_client()
             response = await client.post(f"{self.api_url}/filament", json=data)
             response = await client.post(f"{self.api_url}/filament", json=data)
             response.raise_for_status()
             response.raise_for_status()
             return response.json()
             return response.json()
         except httpx.HTTPStatusError as e:
         except httpx.HTTPStatusError as e:
-            logger.error(f"Failed to create filament in Spoolman: {e}, response: {e.response.text}")
+            logger.error("Failed to create filament in Spoolman: %s, response: %s", e, e.response.text)
             return None
             return None
         except Exception as e:
         except Exception as e:
-            logger.error(f"Failed to create filament in Spoolman: {e}")
+            logger.error("Failed to create filament in Spoolman: %s", e)
             return None
             return None
 
 
     async def create_spool(
     async def create_spool(
@@ -309,18 +309,18 @@ class SpoolmanClient:
             if extra:
             if extra:
                 data["extra"] = extra
                 data["extra"] = extra
 
 
-            logger.debug(f"Creating spool in Spoolman: {data}")
+            logger.debug("Creating spool in Spoolman: %s", data)
             client = await self._get_client()
             client = await self._get_client()
             response = await client.post(f"{self.api_url}/spool", json=data)
             response = await client.post(f"{self.api_url}/spool", json=data)
             response.raise_for_status()
             response.raise_for_status()
             result = response.json()
             result = response.json()
-            logger.info(f"Created spool {result.get('id')} in Spoolman")
+            logger.info("Created spool %s in Spoolman", result.get("id"))
             return result
             return result
         except httpx.HTTPStatusError as e:
         except httpx.HTTPStatusError as e:
-            logger.error(f"Failed to create spool in Spoolman: {e}, response: {e.response.text}")
+            logger.error("Failed to create spool in Spoolman: %s, response: %s", e, e.response.text)
             return None
             return None
         except Exception as e:
         except Exception as e:
-            logger.error(f"Failed to create spool in Spoolman: {e}")
+            logger.error("Failed to create spool in Spoolman: %s", e)
             return None
             return None
 
 
     async def update_spool(
     async def update_spool(
@@ -362,7 +362,7 @@ class SpoolmanClient:
             response.raise_for_status()
             response.raise_for_status()
             return response.json()
             return response.json()
         except Exception as e:
         except Exception as e:
-            logger.error(f"Failed to update spool in Spoolman: {e}")
+            logger.error("Failed to update spool in Spoolman: %s", e)
             return None
             return None
 
 
     async def use_spool(self, spool_id: int, used_weight: float) -> dict | None:
     async def use_spool(self, spool_id: int, used_weight: float) -> dict | None:
@@ -384,7 +384,7 @@ class SpoolmanClient:
             response.raise_for_status()
             response.raise_for_status()
             return response.json()
             return response.json()
         except Exception as e:
         except Exception as e:
-            logger.error(f"Failed to record spool usage in Spoolman: {e}")
+            logger.error("Failed to record spool usage in Spoolman: %s", e)
             return None
             return None
 
 
     async def find_spool_by_tag(self, tag_uid: str) -> dict | None:
     async def find_spool_by_tag(self, tag_uid: str) -> dict | None:
@@ -408,7 +408,7 @@ class SpoolmanClient:
                 if stored_tag:
                 if stored_tag:
                     normalized_tag = stored_tag.strip('"').upper()
                     normalized_tag = stored_tag.strip('"').upper()
                     if normalized_tag == search_tag:
                     if normalized_tag == search_tag:
-                        logger.debug(f"Found spool {spool['id']} matching tag {tag_uid}")
+                        logger.debug("Found spool %s matching tag %s", spool["id"], tag_uid)
                         return spool
                         return spool
         return None
         return None
 
 
@@ -517,11 +517,11 @@ class SpoolmanClient:
                 logger.info("Created 'tag' extra field in Spoolman")
                 logger.info("Created 'tag' extra field in Spoolman")
                 return True
                 return True
 
 
-            logger.warning(f"Failed to create 'tag' extra field: {response.status_code} - {response.text}")
+            logger.warning("Failed to create 'tag' extra field: %s - %s", response.status_code, response.text)
             return False
             return False
 
 
         except Exception as e:
         except Exception as e:
-            logger.warning(f"Failed to ensure 'tag' extra field exists: {e}")
+            logger.warning("Failed to ensure 'tag' extra field exists: %s", e)
             return False
             return False
 
 
     def parse_ams_tray(self, ams_id: int, tray_data: dict) -> AMSTray | None:
     def parse_ams_tray(self, ams_id: int, tray_data: dict) -> AMSTray | None:
@@ -623,7 +623,7 @@ class SpoolmanClient:
             # Bambu Lab preset IDs start with "GF" followed by letter and digits
             # Bambu Lab preset IDs start with "GF" followed by letter and digits
             # e.g., GFA00, GFB00, GFL00, GFN00, GFG00, GFS00, GFU00
             # e.g., GFA00, GFB00, GFL00, GFN00, GFG00, GFS00, GFU00
             if idx and len(idx) >= 3 and idx.startswith("GF"):
             if idx and len(idx) >= 3 and idx.startswith("GF"):
-                logger.debug(f"Identified Bambu Lab spool via tray_info_idx: {idx}")
+                logger.debug("Identified Bambu Lab spool via tray_info_idx: %s", idx)
                 return True
                 return True
 
 
         # Check tray_uuid (preferred - consistent across printer models)
         # Check tray_uuid (preferred - consistent across printer models)
@@ -643,7 +643,7 @@ class SpoolmanClient:
             if len(tag) == 16 and tag != "0000000000000000":
             if len(tag) == 16 and tag != "0000000000000000":
                 try:
                 try:
                     int(tag, 16)
                     int(tag, 16)
-                    logger.debug(f"Identified Bambu Lab spool via tag_uid fallback: {tag}")
+                    logger.debug("Identified Bambu Lab spool via tag_uid fallback: %s", tag)
                     return True
                     return True
                 except ValueError:
                 except ValueError:
                     pass
                     pass
@@ -695,7 +695,7 @@ class SpoolmanClient:
                     f"(tray_info_idx={tray.tray_info_idx}, tray_uuid={tray.tray_uuid}, tag_uid={tray.tag_uid})"
                     f"(tray_info_idx={tray.tray_info_idx}, tray_uuid={tray.tray_uuid}, tag_uid={tray.tag_uid})"
                 )
                 )
             else:
             else:
-                logger.debug(f"Skipping tray without RFID tag: AMS {tray.ams_id} tray {tray.tray_id}")
+                logger.debug("Skipping tray without RFID tag: AMS %s tray %s", tray.ams_id, tray.tray_id)
             return None
             return None
 
 
         # Determine which identifier to use for Spoolman (prefer tray_uuid, fallback to tag_uid)
         # Determine which identifier to use for Spoolman (prefer tray_uuid, fallback to tag_uid)
@@ -719,7 +719,7 @@ class SpoolmanClient:
         existing = await self.find_spool_by_tag(spool_tag)
         existing = await self.find_spool_by_tag(spool_tag)
         if existing:
         if existing:
             # Update existing spool
             # Update existing spool
-            logger.info(f"Updating existing spool {existing['id']} for tag {spool_tag[:16]}...")
+            logger.info("Updating existing spool %s for tag %s...", existing["id"], spool_tag[:16])
             return await self.update_spool(
             return await self.update_spool(
                 spool_id=existing["id"],
                 spool_id=existing["id"],
                 remaining_weight=None if disable_weight_sync else remaining,
                 remaining_weight=None if disable_weight_sync else remaining,
@@ -727,12 +727,12 @@ class SpoolmanClient:
             )
             )
 
 
         # Spool not found - auto-create it
         # Spool not found - auto-create it
-        logger.info(f"Creating new spool in Spoolman for {tray.tray_sub_brands} (tag: {spool_tag[:16]}...)")
+        logger.info("Creating new spool in Spoolman for %s (tag: %s...)", tray.tray_sub_brands, spool_tag[:16])
 
 
         # First find or create the filament type
         # First find or create the filament type
         filament = await self._find_or_create_filament(tray)
         filament = await self._find_or_create_filament(tray)
         if not filament:
         if not filament:
-            logger.error(f"Failed to find or create filament for {tray.tray_sub_brands}")
+            logger.error("Failed to find or create filament for %s", tray.tray_sub_brands)
             return None
             return None
 
 
         # Create the spool with identifier stored as "tag" in extra field
         # Create the spool with identifier stored as "tag" in extra field

+ 26 - 26
backend/app/services/spoolman_tracking.py

@@ -107,13 +107,13 @@ async def store_print_data(printer_id: int, archive_id: int, file_path: str, db,
     # Get 3MF file path
     # Get 3MF file path
     full_path = app_settings.base_dir / file_path
     full_path = app_settings.base_dir / file_path
     if not full_path.exists():
     if not full_path.exists():
-        logger.debug(f"[SPOOLMAN] 3MF file not found: {full_path}")
+        logger.debug("[SPOOLMAN] 3MF file not found: %s", full_path)
         return
         return
 
 
     # Extract per-filament usage from 3MF (total usage per slot)
     # Extract per-filament usage from 3MF (total usage per slot)
     filament_usage = extract_filament_usage_from_3mf(full_path)
     filament_usage = extract_filament_usage_from_3mf(full_path)
     if not filament_usage:
     if not filament_usage:
-        logger.debug(f"[SPOOLMAN] No filament usage data in 3MF for archive {archive_id}")
+        logger.debug("[SPOOLMAN] No filament usage data in 3MF for archive %s", archive_id)
         return
         return
 
 
     # Get current AMS tray state
     # Get current AMS tray state
@@ -140,7 +140,7 @@ async def store_print_data(printer_id: int, archive_id: int, file_path: str, db,
     if layer_usage:
     if layer_usage:
         # Convert int keys to string for JSON serialization
         # Convert int keys to string for JSON serialization
         layer_usage_json = {str(k): v for k, v in layer_usage.items()}
         layer_usage_json = {str(k): v for k, v in layer_usage.items()}
-        logger.debug(f"[SPOOLMAN] Parsed {len(layer_usage)} layers from G-code")
+        logger.debug("[SPOOLMAN] Parsed %s layers from G-code", len(layer_usage))
 
 
     # Extract filament properties (density, diameter) for mm -> grams conversion
     # Extract filament properties (density, diameter) for mm -> grams conversion
     filament_properties = extract_filament_properties_from_3mf(full_path)
     filament_properties = extract_filament_properties_from_3mf(full_path)
@@ -165,11 +165,11 @@ async def store_print_data(printer_id: int, archive_id: int, file_path: str, db,
     db.add(tracking)
     db.add(tracking)
     await db.commit()
     await db.commit()
 
 
-    logger.info(f"[SPOOLMAN] Stored tracking data for print: printer={printer_id}, archive={archive_id}")
-    logger.debug(f"[SPOOLMAN] Filament usage: {filament_usage}")
-    logger.debug(f"[SPOOLMAN] AMS trays: {list(ams_trays.keys())}")
+    logger.info("[SPOOLMAN] Stored tracking data for print: printer=%s, archive=%s", printer_id, archive_id)
+    logger.debug("[SPOOLMAN] Filament usage: %s", filament_usage)
+    logger.debug("[SPOOLMAN] AMS trays: %s", list(ams_trays.keys()))
     if slot_to_tray:
     if slot_to_tray:
-        logger.debug(f"[SPOOLMAN] Custom slot mapping: {slot_to_tray}")
+        logger.debug("[SPOOLMAN] Custom slot mapping: %s", slot_to_tray)
     if layer_usage_json:
     if layer_usage_json:
         logger.debug("[SPOOLMAN] Layer usage data available for partial tracking")
         logger.debug("[SPOOLMAN] Layer usage data available for partial tracking")
 
 
@@ -187,14 +187,14 @@ async def cleanup_tracking(printer_id: int, archive_id: int, db):
     tracking = result.scalar_one_or_none()
     tracking = result.scalar_one_or_none()
 
 
     if not tracking:
     if not tracking:
-        logger.debug(f"[SPOOLMAN] No tracking data to clean up for printer={printer_id}, archive={archive_id}")
+        logger.debug("[SPOOLMAN] No tracking data to clean up for printer=%s, archive=%s", printer_id, archive_id)
         return
         return
 
 
     # Try to report partial usage before cleanup
     # Try to report partial usage before cleanup
     try:
     try:
         await _report_partial_usage(printer_id, tracking)
         await _report_partial_usage(printer_id, tracking)
     except Exception as e:
     except Exception as e:
-        logger.warning(f"[SPOOLMAN] Partial usage report failed: {e}")
+        logger.warning("[SPOOLMAN] Partial usage report failed: %s", e)
 
 
     # Delete tracking data
     # Delete tracking data
     await db.execute(
     await db.execute(
@@ -203,7 +203,7 @@ async def cleanup_tracking(printer_id: int, archive_id: int, db):
         .where(ActivePrintSpoolman.archive_id == archive_id)
         .where(ActivePrintSpoolman.archive_id == archive_id)
     )
     )
     await db.commit()
     await db.commit()
-    logger.debug(f"[SPOOLMAN] Cleaned up tracking data for printer={printer_id}, archive={archive_id}")
+    logger.debug("[SPOOLMAN] Cleaned up tracking data for printer=%s, archive=%s", printer_id, archive_id)
 
 
 
 
 async def _get_spoolman_client_with_fallback():
 async def _get_spoolman_client_with_fallback():
@@ -245,22 +245,22 @@ async def _report_spool_usage_for_slots(
         global_tray_id = _resolve_global_tray_id(slot_id, slot_to_tray)
         global_tray_id = _resolve_global_tray_id(slot_id, slot_to_tray)
         tray_info = ams_trays.get(global_tray_id)
         tray_info = ams_trays.get(global_tray_id)
         if not tray_info:
         if not tray_info:
-            logger.debug(f"[SPOOLMAN] Slot {slot_id}: no tray at global_tray_id {global_tray_id}")
+            logger.debug("[SPOOLMAN] Slot %s: no tray at global_tray_id %s", slot_id, global_tray_id)
             continue
             continue
 
 
         spool_tag = _resolve_spool_tag(tray_info)
         spool_tag = _resolve_spool_tag(tray_info)
         if not spool_tag:
         if not spool_tag:
-            logger.debug(f"[SPOOLMAN] Slot {slot_id}: no identifier for tray {global_tray_id}")
+            logger.debug("[SPOOLMAN] Slot %s: no identifier for tray %s", slot_id, global_tray_id)
             continue
             continue
 
 
         spool = await client.find_spool_by_tag(spool_tag)
         spool = await client.find_spool_by_tag(spool_tag)
         if not spool:
         if not spool:
-            logger.debug(f"[SPOOLMAN] Slot {slot_id}: no spool for tag {spool_tag[:16]}...")
+            logger.debug("[SPOOLMAN] Slot %s: no spool for tag %s...", slot_id, spool_tag[:16])
             continue
             continue
 
 
         result = await client.use_spool(spool["id"], grams_used)
         result = await client.use_spool(spool["id"], grams_used)
         if result:
         if result:
-            logger.info(f"[SPOOLMAN] {method_label}: slot {slot_id}: {grams_used}g -> spool {spool['id']}")
+            logger.info("[SPOOLMAN] %s: slot %s: %sg -> spool %s", method_label, slot_id, grams_used, spool["id"])
             spools_updated += 1
             spools_updated += 1
 
 
     return spools_updated
     return spools_updated
@@ -303,7 +303,7 @@ async def _report_partial_usage(printer_id: int, tracking):
         logger.debug("[SPOOLMAN] No progress to report (layer 0 or unknown)")
         logger.debug("[SPOOLMAN] No progress to report (layer 0 or unknown)")
         return
         return
 
 
-    logger.info(f"[SPOOLMAN] Reporting partial usage at layer {current_layer}/{total_layers or '?'}")
+    logger.info("[SPOOLMAN] Reporting partial usage at layer %s/%s", current_layer, total_layers or "?")
 
 
     # Get tracking data
     # Get tracking data
     layer_usage = tracking.layer_usage
     layer_usage = tracking.layer_usage
@@ -325,7 +325,7 @@ async def _report_partial_usage(printer_id: int, tracking):
         usage_mm = get_cumulative_usage_at_layer(layer_usage_int, current_layer)
         usage_mm = get_cumulative_usage_at_layer(layer_usage_int, current_layer)
 
 
         if usage_mm:
         if usage_mm:
-            logger.info(f"[SPOOLMAN] Using G-code parsed data for layer {current_layer}")
+            logger.info("[SPOOLMAN] Using G-code parsed data for layer %s", current_layer)
 
 
             # Build (slot_id, grams) list using Spoolman densities with 3MF fallback
             # Build (slot_id, grams) list using Spoolman densities with 3MF fallback
             usage_items = []
             usage_items = []
@@ -350,7 +350,7 @@ async def _report_partial_usage(printer_id: int, tracking):
                 if not density:
                 if not density:
                     props = filament_properties.get(str(slot_id), filament_properties.get(slot_id, {}))
                     props = filament_properties.get(str(slot_id), filament_properties.get(slot_id, {}))
                     density = props.get("density", 1.24)
                     density = props.get("density", 1.24)
-                    logger.debug(f"[SPOOLMAN] Using fallback density {density} for slot {slot_id}")
+                    logger.debug("[SPOOLMAN] Using fallback density %s for slot %s", density, slot_id)
 
 
                 grams_used = round(mm_to_grams(mm_used, diameter, density), 2)
                 grams_used = round(mm_to_grams(mm_used, diameter, density), 2)
                 usage_items.append((slot_id, grams_used))
                 usage_items.append((slot_id, grams_used))
@@ -359,16 +359,16 @@ async def _report_partial_usage(printer_id: int, tracking):
                 client, usage_items, ams_trays, slot_to_tray, "Partial (G-code)"
                 client, usage_items, ams_trays, slot_to_tray, "Partial (G-code)"
             )
             )
             if spools_updated > 0:
             if spools_updated > 0:
-                logger.info(f"[SPOOLMAN] Reported partial usage to {spools_updated} spool(s) using G-code data")
+                logger.info("[SPOOLMAN] Reported partial usage to %s spool(s) using G-code data", spools_updated)
             return
             return
 
 
     # Fallback: linear interpolation (if no G-code data available)
     # Fallback: linear interpolation (if no G-code data available)
     if not total_layers or total_layers <= 0:
     if not total_layers or total_layers <= 0:
-        logger.debug(f"[SPOOLMAN] Cannot use linear fallback: total_layers={total_layers}")
+        logger.debug("[SPOOLMAN] Cannot use linear fallback: total_layers=%s", total_layers)
         return
         return
 
 
     progress_ratio = min(current_layer / total_layers, 1.0)
     progress_ratio = min(current_layer / total_layers, 1.0)
-    logger.info(f"[SPOOLMAN] Falling back to linear interpolation ({progress_ratio:.1%})")
+    logger.info("[SPOOLMAN] Falling back to linear interpolation (%s)", progress_ratio)
 
 
     usage_items = []
     usage_items = []
     for usage in filament_usage:
     for usage in filament_usage:
@@ -382,7 +382,7 @@ async def _report_partial_usage(printer_id: int, tracking):
         client, usage_items, ams_trays, slot_to_tray, "Partial (linear)"
         client, usage_items, ams_trays, slot_to_tray, "Partial (linear)"
     )
     )
     if spools_updated > 0:
     if spools_updated > 0:
-        logger.info(f"[SPOOLMAN] Reported partial usage to {spools_updated} spool(s) using linear interpolation")
+        logger.info("[SPOOLMAN] Reported partial usage to %s spool(s) using linear interpolation", spools_updated)
 
 
 
 
 async def report_usage(printer_id: int, archive_id: int):
 async def report_usage(printer_id: int, archive_id: int):
@@ -404,7 +404,7 @@ async def report_usage(printer_id: int, archive_id: int):
         tracking = result.scalar_one_or_none()
         tracking = result.scalar_one_or_none()
 
 
         if not tracking:
         if not tracking:
-            logger.info(f"[SPOOLMAN] No tracking data for print (printer={printer_id}, archive={archive_id})")
+            logger.info("[SPOOLMAN] No tracking data for print (printer=%s, archive=%s)", printer_id, archive_id)
             return
             return
 
 
         filament_usage = tracking.filament_usage or []
         filament_usage = tracking.filament_usage or []
@@ -416,7 +416,7 @@ async def report_usage(printer_id: int, archive_id: int):
         await db.commit()
         await db.commit()
 
 
         if not filament_usage:
         if not filament_usage:
-            logger.debug(f"[SPOOLMAN] No filament usage data for archive {archive_id}")
+            logger.debug("[SPOOLMAN] No filament usage data for archive %s", archive_id)
             return
             return
 
 
         # Check if Spoolman is enabled
         # Check if Spoolman is enabled
@@ -429,7 +429,7 @@ async def report_usage(printer_id: int, archive_id: int):
             logger.warning("[SPOOLMAN] Not reachable for usage reporting")
             logger.warning("[SPOOLMAN] Not reachable for usage reporting")
             return
             return
 
 
-        logger.info(f"[SPOOLMAN] Reporting per-filament usage for archive {archive_id}")
+        logger.info("[SPOOLMAN] Reporting per-filament usage for archive %s", archive_id)
 
 
         usage_items = [(u.get("slot_id", 0), u.get("used_g", 0)) for u in filament_usage]
         usage_items = [(u.get("slot_id", 0), u.get("used_g", 0)) for u in filament_usage]
         spools_updated = await _report_spool_usage_for_slots(
         spools_updated = await _report_spool_usage_for_slots(
@@ -437,6 +437,6 @@ async def report_usage(printer_id: int, archive_id: int):
         )
         )
 
 
         if spools_updated == 0:
         if spools_updated == 0:
-            logger.info(f"[SPOOLMAN] Archive {archive_id}: no spools updated")
+            logger.info("[SPOOLMAN] Archive %s: no spools updated", archive_id)
         else:
         else:
-            logger.info(f"[SPOOLMAN] Archive {archive_id}: updated {spools_updated} spool(s)")
+            logger.info("[SPOOLMAN] Archive %s: updated %s spool(s)", archive_id, spools_updated)

+ 7 - 7
backend/app/services/stl_thumbnail.py

@@ -46,12 +46,12 @@ def generate_stl_thumbnail(
         mesh = trimesh.load(str(stl_path), force="mesh")
         mesh = trimesh.load(str(stl_path), force="mesh")
 
 
         if mesh is None or not hasattr(mesh, "vertices") or len(mesh.vertices) == 0:
         if mesh is None or not hasattr(mesh, "vertices") or len(mesh.vertices) == 0:
-            logger.warning(f"Failed to load STL or empty mesh: {stl_path}")
+            logger.warning("Failed to load STL or empty mesh: %s", stl_path)
             return None
             return None
 
 
         # Simplify large meshes for performance
         # Simplify large meshes for performance
         if len(mesh.vertices) > MAX_VERTICES:
         if len(mesh.vertices) > MAX_VERTICES:
-            logger.info(f"Simplifying mesh from {len(mesh.vertices)} vertices")
+            logger.info("Simplifying mesh from %s vertices", len(mesh.vertices))
             try:
             try:
                 # Calculate reduction ratio (0-1 range)
                 # Calculate reduction ratio (0-1 range)
                 # e.g., 124633 vertices -> 100000 means keep ~80%, so reduce by ~20%
                 # e.g., 124633 vertices -> 100000 means keep ~80%, so reduce by ~20%
@@ -60,9 +60,9 @@ def generate_stl_thumbnail(
                 # Clamp to valid range (0.01 to 0.99)
                 # Clamp to valid range (0.01 to 0.99)
                 target_reduction = max(0.01, min(0.99, target_reduction))
                 target_reduction = max(0.01, min(0.99, target_reduction))
                 mesh = mesh.simplify_quadric_decimation(target_reduction)
                 mesh = mesh.simplify_quadric_decimation(target_reduction)
-                logger.info(f"Simplified mesh to {len(mesh.vertices)} vertices")
+                logger.info("Simplified mesh to %s vertices", len(mesh.vertices))
             except Exception as e:
             except Exception as e:
-                logger.warning(f"Mesh simplification failed, using original: {e}")
+                logger.warning("Mesh simplification failed, using original: %s", e)
 
 
         # Get mesh bounds and center it
         # Get mesh bounds and center it
         vertices = mesh.vertices
         vertices = mesh.vertices
@@ -129,12 +129,12 @@ def generate_stl_thumbnail(
         )
         )
         plt.close(fig)
         plt.close(fig)
 
 
-        logger.info(f"Generated STL thumbnail: {thumb_path}")
+        logger.info("Generated STL thumbnail: %s", thumb_path)
         return str(thumb_path)
         return str(thumb_path)
 
 
     except ImportError as e:
     except ImportError as e:
-        logger.warning(f"STL thumbnail generation unavailable (missing dependencies): {e}")
+        logger.warning("STL thumbnail generation unavailable (missing dependencies): %s", e)
         return None
         return None
     except Exception as e:
     except Exception as e:
-        logger.warning(f"Failed to generate STL thumbnail for {stl_path}: {e}")
+        logger.warning("Failed to generate STL thumbnail for %s: %s", stl_path, e)
         return None
         return None

+ 9 - 9
backend/app/services/tasmota.py

@@ -48,16 +48,16 @@ class TasmotaService:
                 response.raise_for_status()
                 response.raise_for_status()
                 return response.json()
                 return response.json()
         except httpx.TimeoutException:
         except httpx.TimeoutException:
-            logger.warning(f"Tasmota device at {ip} timed out")
+            logger.warning("Tasmota device at %s timed out", ip)
             return None
             return None
         except httpx.HTTPStatusError as e:
         except httpx.HTTPStatusError as e:
-            logger.warning(f"Tasmota device at {ip} returned error: {e}")
+            logger.warning("Tasmota device at %s returned error: %s", ip, e)
             return None
             return None
         except httpx.RequestError as e:
         except httpx.RequestError as e:
-            logger.warning(f"Failed to connect to Tasmota device at {ip}: {e}")
+            logger.warning("Failed to connect to Tasmota device at %s: %s", ip, e)
             return None
             return None
         except Exception as e:
         except Exception as e:
-            logger.error(f"Unexpected error communicating with Tasmota at {ip}: {e}")
+            logger.error("Unexpected error communicating with Tasmota at %s: %s", ip, e)
             return None
             return None
 
 
     async def get_status(self, plug: "SmartPlug") -> dict:
     async def get_status(self, plug: "SmartPlug") -> dict:
@@ -95,9 +95,9 @@ class TasmotaService:
         success = state == "ON"
         success = state == "ON"
 
 
         if success:
         if success:
-            logger.info(f"Turned ON smart plug '{plug.name}' at {plug.ip_address}")
+            logger.info("Turned ON smart plug '%s' at %s", plug.name, plug.ip_address)
         else:
         else:
-            logger.warning(f"Failed to turn ON smart plug '{plug.name}' at {plug.ip_address}")
+            logger.warning("Failed to turn ON smart plug '%s' at %s", plug.name, plug.ip_address)
 
 
         return success
         return success
 
 
@@ -113,9 +113,9 @@ class TasmotaService:
         success = state == "OFF"
         success = state == "OFF"
 
 
         if success:
         if success:
-            logger.info(f"Turned OFF smart plug '{plug.name}' at {plug.ip_address}")
+            logger.info("Turned OFF smart plug '%s' at %s", plug.name, plug.ip_address)
         else:
         else:
-            logger.warning(f"Failed to turn OFF smart plug '{plug.name}' at {plug.ip_address}")
+            logger.warning("Failed to turn OFF smart plug '%s' at %s", plug.name, plug.ip_address)
 
 
         return success
         return success
 
 
@@ -130,7 +130,7 @@ class TasmotaService:
         success = state in ["ON", "OFF"]
         success = state in ["ON", "OFF"]
 
 
         if success:
         if success:
-            logger.info(f"Toggled smart plug '{plug.name}' at {plug.ip_address} to {state}")
+            logger.info("Toggled smart plug '%s' at %s to %s", plug.name, plug.ip_address, state)
 
 
         return success
         return success
 
 

+ 3 - 3
backend/app/services/timelapse_processor.py

@@ -43,7 +43,7 @@ class TimelapseProcessor:
         stdout, stderr = await process.communicate()
         stdout, stderr = await process.communicate()
 
 
         if process.returncode != 0:
         if process.returncode != 0:
-            logger.error(f"ffprobe failed: {stderr.decode()}")
+            logger.error("ffprobe failed: %s", stderr.decode())
             raise RuntimeError(f"ffprobe failed: {stderr.decode()}")
             raise RuntimeError(f"ffprobe failed: {stderr.decode()}")
 
 
         data = json.loads(stdout.decode())
         data = json.loads(stdout.decode())
@@ -218,7 +218,7 @@ class TimelapseProcessor:
             ]
             ]
         )
         )
 
 
-        logger.info(f"Processing timelapse: {' '.join(cmd)}")
+        logger.info("Processing timelapse: %s", " ".join(cmd))
 
 
         # Run FFmpeg
         # Run FFmpeg
         process = await asyncio.create_subprocess_exec(
         process = await asyncio.create_subprocess_exec(
@@ -230,7 +230,7 @@ class TimelapseProcessor:
         _, stderr = await process.communicate()
         _, stderr = await process.communicate()
 
 
         if process.returncode != 0:
         if process.returncode != 0:
-            logger.error(f"FFmpeg processing failed: {stderr.decode()}")
+            logger.error("FFmpeg processing failed: %s", stderr.decode())
             return False
             return False
 
 
         return output_path.exists()
         return output_path.exists()

+ 9 - 9
backend/app/services/virtual_printer/certificate.py

@@ -36,7 +36,7 @@ def _get_local_ip() -> str:
         ip = s.getsockname()[0]
         ip = s.getsockname()[0]
         s.close()
         s.close()
         return ip
         return ip
-    except Exception:
+    except OSError:
         return "127.0.0.1"
         return "127.0.0.1"
 
 
 
 
@@ -92,18 +92,18 @@ class CertificateService:
             now = datetime.now(timezone.utc)
             now = datetime.now(timezone.utc)
             days_remaining = (ca_cert.not_valid_after_utc - now).days
             days_remaining = (ca_cert.not_valid_after_utc - now).days
             if days_remaining < CA_EXPIRY_THRESHOLD_DAYS:
             if days_remaining < CA_EXPIRY_THRESHOLD_DAYS:
-                logger.warning(f"CA certificate expires in {days_remaining} days, will regenerate")
+                logger.warning("CA certificate expires in %s days, will regenerate", days_remaining)
                 return None
                 return None
 
 
             # Load CA private key
             # Load CA private key
             ca_key_pem = self.ca_key_path.read_bytes()
             ca_key_pem = self.ca_key_path.read_bytes()
             ca_key = serialization.load_pem_private_key(ca_key_pem, password=None)
             ca_key = serialization.load_pem_private_key(ca_key_pem, password=None)
 
 
-            logger.info(f"Using existing CA certificate (expires in {days_remaining} days)")
+            logger.info("Using existing CA certificate (expires in %s days)", days_remaining)
             return ca_key, ca_cert
             return ca_key, ca_cert
 
 
-        except Exception as e:
-            logger.warning(f"Failed to load existing CA: {e}")
+        except (OSError, ValueError) as e:
+            logger.warning("Failed to load existing CA: %s", e)
             return None
             return None
 
 
     def _get_or_create_ca(self) -> tuple[rsa.RSAPrivateKey, x509.Certificate]:
     def _get_or_create_ca(self) -> tuple[rsa.RSAPrivateKey, x509.Certificate]:
@@ -203,7 +203,7 @@ class CertificateService:
         Returns:
         Returns:
             Tuple of (cert_path, key_path)
             Tuple of (cert_path, key_path)
         """
         """
-        logger.info(f"Generating certificates for virtual printer (serial: {self.serial})...")
+        logger.info("Generating certificates for virtual printer (serial: %s)...", self.serial)
 
 
         # Ensure directory exists
         # Ensure directory exists
         self.cert_dir.mkdir(parents=True, exist_ok=True)
         self.cert_dir.mkdir(parents=True, exist_ok=True)
@@ -229,7 +229,7 @@ class CertificateService:
 
 
         now = datetime.now(timezone.utc)
         now = datetime.now(timezone.utc)
         local_ip = _get_local_ip()
         local_ip = _get_local_ip()
-        logger.info(f"Generating printer certificate with CN={self.serial}, local IP: {local_ip}")
+        logger.info("Generating printer certificate with CN=%s, local IP: %s", self.serial, local_ip)
 
 
         # Build printer certificate signed by CA
         # Build printer certificate signed by CA
         printer_cert = (
         printer_cert = (
@@ -298,9 +298,9 @@ class CertificateService:
         )
         )
         self.cert_path.write_bytes(cert_chain)
         self.cert_path.write_bytes(cert_chain)
 
 
-        logger.info(f"Generated certificate chain at {self.cert_dir}")
+        logger.info("Generated certificate chain at %s", self.cert_dir)
         logger.info("  CA: CN=Virtual Printer CA")
         logger.info("  CA: CN=Virtual Printer CA")
-        logger.info(f"  Printer: CN={self.serial}")
+        logger.info("  Printer: CN=%s", self.serial)
         return self.cert_path, self.key_path
         return self.cert_path, self.key_path
 
 
     def delete_printer_certificate(self) -> None:
     def delete_printer_certificate(self) -> None:

+ 33 - 33
backend/app/services/virtual_printer/ftp_server.py

@@ -57,7 +57,7 @@ class FTPSession:
     async def send(self, code: int, message: str) -> None:
     async def send(self, code: int, message: str) -> None:
         """Send an FTP response."""
         """Send an FTP response."""
         response = f"{code} {message}\r\n"
         response = f"{code} {message}\r\n"
-        logger.info(f"FTP -> {self.remote_ip}: {response.strip()}")
+        logger.info("FTP -> %s: %s", self.remote_ip, response.strip())
         self.writer.write(response.encode("utf-8"))
         self.writer.write(response.encode("utf-8"))
         await self.writer.drain()
         await self.writer.drain()
 
 
@@ -74,7 +74,7 @@ class FTPSession:
                         timeout=300,  # 5 minute timeout
                         timeout=300,  # 5 minute timeout
                     )
                     )
                 except TimeoutError:
                 except TimeoutError:
-                    logger.debug(f"FTP session timeout from {self.remote_ip}")
+                    logger.debug("FTP session timeout from %s", self.remote_ip)
                     break
                     break
 
 
                 if not line:
                 if not line:
@@ -88,7 +88,7 @@ class FTPSession:
                 if not command_line:
                 if not command_line:
                     continue
                     continue
 
 
-                logger.info(f"FTP <- {self.remote_ip}: {command_line}")
+                logger.info("FTP <- %s: %s", self.remote_ip, command_line)
 
 
                 # Parse command and argument
                 # Parse command and argument
                 parts = command_line.split(" ", 1)
                 parts = command_line.split(" ", 1)
@@ -100,15 +100,15 @@ class FTPSession:
                 if handler:
                 if handler:
                     await handler(arg)
                     await handler(arg)
                 else:
                 else:
-                    logger.warning(f"FTP command not implemented: {cmd}")
+                    logger.warning("FTP command not implemented: %s", cmd)
                     await self.send(502, f"Command {cmd} not implemented")
                     await self.send(502, f"Command {cmd} not implemented")
 
 
         except asyncio.CancelledError:
         except asyncio.CancelledError:
-            logger.info(f"FTP session cancelled from {self.remote_ip}")
+            logger.info("FTP session cancelled from %s", self.remote_ip)
         except Exception as e:
         except Exception as e:
-            logger.error(f"FTP session error from {self.remote_ip}: {e}")
+            logger.error("FTP session error from %s: %s", self.remote_ip, e)
         finally:
         finally:
-            logger.info(f"FTP session ended from {self.remote_ip}")
+            logger.info("FTP session ended from %s", self.remote_ip)
             await self._cleanup()
             await self._cleanup()
 
 
     async def _cleanup(self) -> None:
     async def _cleanup(self) -> None:
@@ -117,14 +117,14 @@ class FTPSession:
             self.data_server.close()
             self.data_server.close()
             try:
             try:
                 await self.data_server.wait_closed()
                 await self.data_server.wait_closed()
-            except Exception:
+            except OSError:
                 pass
                 pass
             self.data_server = None
             self.data_server = None
 
 
         try:
         try:
             self.writer.close()
             self.writer.close()
             await self.writer.wait_closed()
             await self.writer.wait_closed()
-        except Exception:
+        except OSError:
             pass
             pass
 
 
     # FTP Commands
     # FTP Commands
@@ -143,10 +143,10 @@ class FTPSession:
             if arg == self.access_code:
             if arg == self.access_code:
                 self.authenticated = True
                 self.authenticated = True
                 await self.send(230, "Login successful")
                 await self.send(230, "Login successful")
-                logger.info(f"FTP login from {self.remote_ip}")
+                logger.info("FTP login from %s", self.remote_ip)
             else:
             else:
                 await self.send(530, "Login incorrect")
                 await self.send(530, "Login incorrect")
-                logger.warning(f"FTP failed login from {self.remote_ip}")
+                logger.warning("FTP failed login from %s", self.remote_ip)
         else:
         else:
             await self.send(503, "Login with USER first")
             await self.send(503, "Login with USER first")
 
 
@@ -224,10 +224,10 @@ class FTPSession:
 
 
             # EPSV response format: 229 Entering Extended Passive Mode (|||port|)
             # EPSV response format: 229 Entering Extended Passive Mode (|||port|)
             await self.send(229, f"Entering Extended Passive Mode (|||{self.data_port}|)")
             await self.send(229, f"Entering Extended Passive Mode (|||{self.data_port}|)")
-            logger.info(f"FTP EPSV listening on port {self.data_port}")
+            logger.info("FTP EPSV listening on port %s", self.data_port)
 
 
         except Exception as e:
         except Exception as e:
-            logger.error(f"Failed to create EPSV data connection: {e}")
+            logger.error("Failed to create EPSV data connection: %s", e)
             await self.send(425, "Cannot open data connection")
             await self.send(425, "Cannot open data connection")
 
 
     async def cmd_PASV(self, arg: str) -> None:
     async def cmd_PASV(self, arg: str) -> None:
@@ -270,10 +270,10 @@ class FTPSession:
                 227,
                 227,
                 f"Entering Passive Mode ({ip_parts[0]},{ip_parts[1]},{ip_parts[2]},{ip_parts[3]},{port_hi},{port_lo})",
                 f"Entering Passive Mode ({ip_parts[0]},{ip_parts[1]},{ip_parts[2]},{ip_parts[3]},{port_hi},{port_lo})",
             )
             )
-            logger.info(f"FTP PASV listening on port {self.data_port}")
+            logger.info("FTP PASV listening on port %s", self.data_port)
 
 
         except Exception as e:
         except Exception as e:
-            logger.error(f"Failed to create passive data connection: {e}")
+            logger.error("Failed to create passive data connection: %s", e)
             await self.send(425, "Cannot open data connection")
             await self.send(425, "Cannot open data connection")
 
 
     async def _handle_data_connection(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
     async def _handle_data_connection(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
@@ -286,9 +286,9 @@ class FTPSession:
                 f"version={ssl_obj.version()}, session_reused={ssl_obj.session_reused}"
                 f"version={ssl_obj.version()}, session_reused={ssl_obj.session_reused}"
             )
             )
         else:
         else:
-            logger.warning(f"FTP data connection from {self.remote_ip} has no SSL!")
+            logger.warning("FTP data connection from %s has no SSL!", self.remote_ip)
 
 
-        logger.info(f"FTP data connection established from {self.remote_ip}")
+        logger.info("FTP data connection established from %s", self.remote_ip)
         self._data_reader = reader
         self._data_reader = reader
         self._data_writer = writer
         self._data_writer = writer
         self._data_connected.set()
         self._data_connected.set()
@@ -302,7 +302,7 @@ class FTPSession:
             try:
             try:
                 self._data_writer.close()
                 self._data_writer.close()
                 await self._data_writer.wait_closed()
                 await self._data_writer.wait_closed()
-            except Exception:
+            except OSError:
                 pass
                 pass
             self._data_writer = None
             self._data_writer = None
             self._data_reader = None
             self._data_reader = None
@@ -311,7 +311,7 @@ class FTPSession:
             try:
             try:
                 self.data_server.close()
                 self.data_server.close()
                 await self.data_server.wait_closed()
                 await self.data_server.wait_closed()
-            except Exception:
+            except OSError:
                 pass
                 pass
             self.data_server = None
             self.data_server = None
 
 
@@ -332,7 +332,7 @@ class FTPSession:
         filename = Path(arg).name  # Sanitize filename
         filename = Path(arg).name  # Sanitize filename
         file_path = self.upload_dir / filename
         file_path = self.upload_dir / filename
 
 
-        logger.info(f"FTP receiving file: {filename} from {self.remote_ip}")
+        logger.info("FTP receiving file: %s from %s", filename, self.remote_ip)
 
 
         await self.send(150, f"Opening data connection for {filename}")
         await self.send(150, f"Opening data connection for {filename}")
 
 
@@ -358,14 +358,14 @@ class FTPSession:
                 if not chunk:
                 if not chunk:
                     break
                     break
                 data_content.append(chunk)
                 data_content.append(chunk)
-                logger.debug(f"FTP received chunk: {len(chunk)} bytes")
+                logger.debug("FTP received chunk: %s bytes", len(chunk))
         except TimeoutError:
         except TimeoutError:
             logger.error("FTP data transfer timeout")
             logger.error("FTP data transfer timeout")
             await self.send(426, "Transfer timeout")
             await self.send(426, "Transfer timeout")
             await self._close_data_connection()
             await self._close_data_connection()
             return
             return
         except Exception as e:
         except Exception as e:
-            logger.error(f"FTP data transfer error: {e}")
+            logger.error("FTP data transfer error: %s", e)
             await self.send(426, f"Transfer failed: {e}")
             await self.send(426, f"Transfer failed: {e}")
             await self._close_data_connection()
             await self._close_data_connection()
             return
             return
@@ -377,7 +377,7 @@ class FTPSession:
         try:
         try:
             total_size = sum(len(c) for c in data_content)
             total_size = sum(len(c) for c in data_content)
             file_path.write_bytes(b"".join(data_content))
             file_path.write_bytes(b"".join(data_content))
-            logger.info(f"FTP saved file: {file_path} ({total_size} bytes)")
+            logger.info("FTP saved file: %s (%s bytes)", file_path, total_size)
             await self.send(226, "Transfer complete")
             await self.send(226, "Transfer complete")
 
 
             # Notify callback
             # Notify callback
@@ -387,10 +387,10 @@ class FTPSession:
                     if asyncio.iscoroutine(result):
                     if asyncio.iscoroutine(result):
                         await result
                         await result
                 except Exception as e:
                 except Exception as e:
-                    logger.error(f"File received callback error: {e}")
+                    logger.error("File received callback error: %s", e)
 
 
         except Exception as e:
         except Exception as e:
-            logger.error(f"Failed to save file {file_path}: {e}")
+            logger.error("Failed to save file %s: %s", file_path, e)
             await self.send(550, "Failed to save file")
             await self.send(550, "Failed to save file")
 
 
     async def cmd_SIZE(self, arg: str) -> None:
     async def cmd_SIZE(self, arg: str) -> None:
@@ -493,7 +493,7 @@ class VirtualPrinterFTPServer:
         if self._running:
         if self._running:
             return
             return
 
 
-        logger.info(f"Starting virtual printer implicit FTPS on port {self.port}")
+        logger.info("Starting virtual printer implicit FTPS on port %s", self.port)
 
 
         # Ensure upload directory exists
         # Ensure upload directory exists
         self.upload_dir.mkdir(parents=True, exist_ok=True)
         self.upload_dir.mkdir(parents=True, exist_ok=True)
@@ -520,27 +520,27 @@ class VirtualPrinterFTPServer:
             )
             )
             self._running = True
             self._running = True
 
 
-            logger.info(f"Implicit FTPS server started on port {self.port}")
+            logger.info("Implicit FTPS server started on port %s", self.port)
 
 
             async with self._server:
             async with self._server:
                 await self._server.serve_forever()
                 await self._server.serve_forever()
 
 
         except OSError as e:
         except OSError as e:
             if e.errno == 98:  # Address already in use
             if e.errno == 98:  # Address already in use
-                logger.error(f"FTP port {self.port} is already in use")
+                logger.error("FTP port %s is already in use", self.port)
             else:
             else:
-                logger.error(f"FTP server error: {e}")
+                logger.error("FTP server error: %s", e)
         except asyncio.CancelledError:
         except asyncio.CancelledError:
             logger.debug("FTP server task cancelled")
             logger.debug("FTP server task cancelled")
         except Exception as e:
         except Exception as e:
-            logger.error(f"FTP server error: {e}")
+            logger.error("FTP server error: %s", e)
         finally:
         finally:
             await self.stop()
             await self.stop()
 
 
     async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
     async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
         """Handle a new FTP client connection."""
         """Handle a new FTP client connection."""
         peername = writer.get_extra_info("peername")
         peername = writer.get_extra_info("peername")
-        logger.info(f"FTP connection from {peername}")
+        logger.info("FTP connection from %s", peername)
 
 
         session = FTPSession(
         session = FTPSession(
             reader=reader,
             reader=reader,
@@ -580,6 +580,6 @@ class VirtualPrinterFTPServer:
             try:
             try:
                 self._server.close()
                 self._server.close()
                 await self._server.wait_closed()
                 await self._server.wait_closed()
-            except Exception as e:
-                logger.debug(f"Error closing FTP server: {e}")
+            except OSError as e:
+                logger.debug("Error closing FTP server: %s", e)
             self._server = None
             self._server = None

+ 35 - 33
backend/app/services/virtual_printer/manager.py

@@ -133,7 +133,7 @@ class VirtualPrinterManager:
             self._cert_dir,
             self._cert_dir,
         ]
         ]
 
 
-        logger.info(f"Checking virtual printer directories in {self._base_dir}")
+        logger.info("Checking virtual printer directories in %s", self._base_dir)
 
 
         for dir_path in dirs_to_create:
         for dir_path in dirs_to_create:
             try:
             try:
@@ -260,20 +260,20 @@ class VirtualPrinterManager:
             await self._stop()
             await self._stop()
         elif enabled and self._enabled and needs_restart:
         elif enabled and self._enabled and needs_restart:
             # Configuration changed while running - restart services
             # Configuration changed while running - restart services
-            logger.info(f"Configuration changed (mode={old_mode}→{mode}), restarting...")
+            logger.info("Configuration changed (mode=%s→%s), restarting...", old_mode, mode)
             await self._stop()
             await self._stop()
             # Give time for ports to be released
             # Give time for ports to be released
             await asyncio.sleep(0.5)
             await asyncio.sleep(0.5)
             await self._start()
             await self._start()
             logger.info("Virtual printer restarted with new configuration")
             logger.info("Virtual printer restarted with new configuration")
         else:
         else:
-            logger.debug(f"No state change needed (enabled={enabled}, self._enabled={self._enabled})")
+            logger.debug("No state change needed (enabled=%s, self._enabled=%s)", enabled, self._enabled)
 
 
         self._enabled = enabled
         self._enabled = enabled
 
 
     async def _start(self) -> None:
     async def _start(self) -> None:
         """Start all virtual printer services."""
         """Start all virtual printer services."""
-        logger.info(f"Starting virtual printer services (mode={self._mode})...")
+        logger.info("Starting virtual printer services (mode=%s)...", self._mode)
 
 
         # Proxy mode uses different services
         # Proxy mode uses different services
         if self._mode == "proxy":
         if self._mode == "proxy":
@@ -285,12 +285,12 @@ class VirtualPrinterManager:
 
 
     async def _start_proxy_mode(self) -> None:
     async def _start_proxy_mode(self) -> None:
         """Start virtual printer in proxy mode (TLS terminating relay)."""
         """Start virtual printer in proxy mode (TLS terminating relay)."""
-        logger.info(f"Starting proxy mode to {self._target_printer_ip}")
+        logger.info("Starting proxy mode to %s", self._target_printer_ip)
 
 
         # In proxy mode, use the REAL printer's serial number
         # In proxy mode, use the REAL printer's serial number
         # This ensures MQTT topic subscriptions match the real printer's topics
         # This ensures MQTT topic subscriptions match the real printer's topics
         proxy_serial = self._target_printer_serial or self.printer_serial
         proxy_serial = self._target_printer_serial or self.printer_serial
-        logger.info(f"Proxy mode using serial: {proxy_serial}")
+        logger.info("Proxy mode using serial: %s", proxy_serial)
 
 
         # Update certificate service with the real printer's serial
         # Update certificate service with the real printer's serial
         self._cert_service.serial = proxy_serial
         self._cert_service.serial = proxy_serial
@@ -298,7 +298,7 @@ class VirtualPrinterManager:
         # Regenerate printer cert if needed (CA is preserved)
         # Regenerate printer cert if needed (CA is preserved)
         self._cert_service.delete_printer_certificate()
         self._cert_service.delete_printer_certificate()
         cert_path, key_path = self._cert_service.generate_certificates()
         cert_path, key_path = self._cert_service.generate_certificates()
-        logger.info(f"Generated certificate for proxy serial: {proxy_serial}")
+        logger.info("Generated certificate for proxy serial: %s", proxy_serial)
 
 
         # Initialize TLS proxy with our certificates
         # Initialize TLS proxy with our certificates
         self._proxy = SlicerProxyManager(
         self._proxy = SlicerProxyManager(
@@ -313,7 +313,7 @@ class VirtualPrinterManager:
             try:
             try:
                 await coro
                 await coro
             except Exception as e:
             except Exception as e:
-                logger.error(f"Virtual printer {name} failed: {e}")
+                logger.error("Virtual printer %s failed: %s", name, e)
 
 
         self._tasks = []
         self._tasks = []
 
 
@@ -388,7 +388,7 @@ class VirtualPrinterManager:
         # Regenerate printer cert if serial changed (CA is preserved)
         # Regenerate printer cert if serial changed (CA is preserved)
         self._cert_service.delete_printer_certificate()
         self._cert_service.delete_printer_certificate()
         cert_path, key_path = self._cert_service.generate_certificates()
         cert_path, key_path = self._cert_service.generate_certificates()
-        logger.info(f"Generated certificate for serial: {current_serial}")
+        logger.info("Generated certificate for serial: %s", current_serial)
 
 
         # Create directories
         # Create directories
         self._upload_dir.mkdir(parents=True, exist_ok=True)
         self._upload_dir.mkdir(parents=True, exist_ok=True)
@@ -423,7 +423,7 @@ class VirtualPrinterManager:
             try:
             try:
                 await coro
                 await coro
             except Exception as e:
             except Exception as e:
-                logger.error(f"Virtual printer {name} failed: {e}")
+                logger.error("Virtual printer %s failed: %s", name, e)
 
 
         self._tasks = [
         self._tasks = [
             asyncio.create_task(run_with_logging(self._ssdp.start(), "SSDP"), name="virtual_printer_ssdp"),
             asyncio.create_task(run_with_logging(self._ssdp.start(), "SSDP"), name="virtual_printer_ssdp"),
@@ -431,11 +431,11 @@ class VirtualPrinterManager:
             asyncio.create_task(run_with_logging(self._mqtt.start(), "MQTT"), name="virtual_printer_mqtt"),
             asyncio.create_task(run_with_logging(self._mqtt.start(), "MQTT"), name="virtual_printer_mqtt"),
         ]
         ]
 
 
-        logger.info(f"Virtual printer '{self.PRINTER_NAME}' started (serial: {self.printer_serial})")
+        logger.info("Virtual printer '%s' started (serial: %s)", self.PRINTER_NAME, self.printer_serial)
 
 
     def _on_proxy_activity(self, name: str, message: str) -> None:
     def _on_proxy_activity(self, name: str, message: str) -> None:
         """Handle proxy activity for logging."""
         """Handle proxy activity for logging."""
-        logger.info(f"Proxy {name}: {message}")
+        logger.info("Proxy %s: %s", name, message)
 
 
     async def _stop(self) -> None:
     async def _stop(self) -> None:
         """Stop all virtual printer services."""
         """Stop all virtual printer services."""
@@ -483,7 +483,7 @@ class VirtualPrinterManager:
             file_path: Path to uploaded file
             file_path: Path to uploaded file
             source_ip: IP address of the uploading slicer
             source_ip: IP address of the uploading slicer
         """
         """
-        logger.info(f"Virtual printer received file: {file_path.name} from {source_ip}")
+        logger.info("Virtual printer received file: %s from %s", file_path.name, source_ip)
 
 
         # Store file reference for MQTT correlation
         # Store file reference for MQTT correlation
         self._pending_files[file_path.name] = file_path
         self._pending_files[file_path.name] = file_path
@@ -510,8 +510,8 @@ class VirtualPrinterManager:
             filename: Name of the file to print
             filename: Name of the file to print
             data: Print command data (contains settings like timelapse, bed_leveling, etc.)
             data: Print command data (contains settings like timelapse, bed_leveling, etc.)
         """
         """
-        logger.info(f"Virtual printer received print command for: {filename}")
-        logger.debug(f"Print command data: {data}")
+        logger.info("Virtual printer received print command for: %s", filename)
+        logger.debug("Print command data: %s", data)
 
 
         # The file should already be archived from FTP upload
         # The file should already be archived from FTP upload
         # This command just confirms the slicer's intent to "print"
         # This command just confirms the slicer's intent to "print"
@@ -529,12 +529,12 @@ class VirtualPrinterManager:
 
 
         # Only archive 3MF files
         # Only archive 3MF files
         if file_path.suffix.lower() != ".3mf":
         if file_path.suffix.lower() != ".3mf":
-            logger.debug(f"Skipping non-3MF file: {file_path.name}")
+            logger.debug("Skipping non-3MF file: %s", file_path.name)
             # Remove from pending and clean up
             # Remove from pending and clean up
             self._pending_files.pop(file_path.name, None)
             self._pending_files.pop(file_path.name, None)
             try:
             try:
                 file_path.unlink()
                 file_path.unlink()
-            except Exception:
+            except OSError:
                 pass
                 pass
             return
             return
 
 
@@ -556,21 +556,21 @@ class VirtualPrinterManager:
                 )
                 )
 
 
                 if archive:
                 if archive:
-                    logger.info(f"Archived virtual printer upload: {archive.id} - {archive.print_name}")
+                    logger.info("Archived virtual printer upload: %s - %s", archive.id, archive.print_name)
 
 
                     # Clean up uploaded file (it's now copied to archive)
                     # Clean up uploaded file (it's now copied to archive)
                     try:
                     try:
                         file_path.unlink()
                         file_path.unlink()
-                    except Exception:
+                    except OSError:
                         pass
                         pass
 
 
                     # Remove from pending
                     # Remove from pending
                     self._pending_files.pop(file_path.name, None)
                     self._pending_files.pop(file_path.name, None)
                 else:
                 else:
-                    logger.error(f"Failed to archive file: {file_path.name}")
+                    logger.error("Failed to archive file: %s", file_path.name)
 
 
-        except Exception as e:
-            logger.error(f"Error archiving file: {e}")
+        except Exception as e:  # Mixed async DB + archive operations
+            logger.error("Error archiving file: %s", e)
 
 
     async def _queue_file(self, file_path: Path, source_ip: str) -> None:
     async def _queue_file(self, file_path: Path, source_ip: str) -> None:
         """Queue file for user review.
         """Queue file for user review.
@@ -585,7 +585,7 @@ class VirtualPrinterManager:
 
 
         # Only queue 3MF files
         # Only queue 3MF files
         if file_path.suffix.lower() != ".3mf":
         if file_path.suffix.lower() != ".3mf":
-            logger.warning(f"Skipping non-3MF file: {file_path.name}")
+            logger.warning("Skipping non-3MF file: %s", file_path.name)
             return
             return
 
 
         try:
         try:
@@ -603,13 +603,13 @@ class VirtualPrinterManager:
                 db.add(pending)
                 db.add(pending)
                 await db.commit()
                 await db.commit()
 
 
-                logger.info(f"Queued virtual printer upload: {pending.id} - {file_path.name}")
+                logger.info("Queued virtual printer upload: %s - %s", pending.id, file_path.name)
 
 
                 # Remove from pending files dict
                 # Remove from pending files dict
                 self._pending_files.pop(file_path.name, None)
                 self._pending_files.pop(file_path.name, None)
 
 
         except Exception as e:
         except Exception as e:
-            logger.error(f"Error queueing file: {e}")
+            logger.error("Error queueing file: %s", e)
 
 
     async def _add_to_print_queue(self, file_path: Path, source_ip: str) -> None:
     async def _add_to_print_queue(self, file_path: Path, source_ip: str) -> None:
         """Archive file and add to print queue (unassigned).
         """Archive file and add to print queue (unassigned).
@@ -624,11 +624,11 @@ class VirtualPrinterManager:
 
 
         # Only process 3MF files
         # Only process 3MF files
         if file_path.suffix.lower() != ".3mf":
         if file_path.suffix.lower() != ".3mf":
-            logger.debug(f"Skipping non-3MF file: {file_path.name}")
+            logger.debug("Skipping non-3MF file: %s", file_path.name)
             self._pending_files.pop(file_path.name, None)
             self._pending_files.pop(file_path.name, None)
             try:
             try:
                 file_path.unlink()
                 file_path.unlink()
-            except Exception:
+            except OSError:
                 pass
                 pass
             return
             return
 
 
@@ -651,7 +651,7 @@ class VirtualPrinterManager:
                 )
                 )
 
 
                 if archive:
                 if archive:
-                    logger.info(f"Archived virtual printer upload: {archive.id} - {archive.print_name}")
+                    logger.info("Archived virtual printer upload: %s - %s", archive.id, archive.print_name)
 
 
                     # Now add to print queue (unassigned)
                     # Now add to print queue (unassigned)
                     queue_item = PrintQueueItem(
                     queue_item = PrintQueueItem(
@@ -663,21 +663,23 @@ class VirtualPrinterManager:
                     db.add(queue_item)
                     db.add(queue_item)
                     await db.commit()
                     await db.commit()
 
 
-                    logger.info(f"Added to print queue (unassigned): queue_id={queue_item.id}, archive_id={archive.id}")
+                    logger.info(
+                        "Added to print queue (unassigned): queue_id=%s, archive_id=%s", queue_item.id, archive.id
+                    )
 
 
                     # Clean up uploaded file (it's now copied to archive)
                     # Clean up uploaded file (it's now copied to archive)
                     try:
                     try:
                         file_path.unlink()
                         file_path.unlink()
-                    except Exception:
+                    except OSError:
                         pass
                         pass
 
 
                     # Remove from pending
                     # Remove from pending
                     self._pending_files.pop(file_path.name, None)
                     self._pending_files.pop(file_path.name, None)
                 else:
                 else:
-                    logger.error(f"Failed to archive file: {file_path.name}")
+                    logger.error("Failed to archive file: %s", file_path.name)
 
 
-        except Exception as e:
-            logger.error(f"Error adding to print queue: {e}")
+        except Exception as e:  # Mixed async DB + archive + queue operations
+            logger.error("Error adding to print queue: %s", e)
 
 
     def get_status(self) -> dict:
     def get_status(self) -> dict:
         """Get virtual printer status.
         """Get virtual printer status.

+ 51 - 51
backend/app/services/virtual_printer/mqtt_server.py

@@ -68,7 +68,7 @@ class VirtualPrinterMQTTServer:
             logger.error("amqtt not installed. Run: pip install amqtt")
             logger.error("amqtt not installed. Run: pip install amqtt")
             return
             return
 
 
-        logger.info(f"Starting virtual printer MQTT broker on port {self.port}")
+        logger.info("Starting virtual printer MQTT broker on port %s", self.port)
 
 
         # Build broker configuration
         # Build broker configuration
         config = {
         config = {
@@ -101,7 +101,7 @@ class VirtualPrinterMQTTServer:
 
 
             # Start the broker
             # Start the broker
             await self._broker.start()
             await self._broker.start()
-            logger.info(f"MQTT broker started on port {self.port}")
+            logger.info("MQTT broker started on port %s", self.port)
 
 
             # Keep running
             # Keep running
             while self._running:
             while self._running:
@@ -109,13 +109,13 @@ class VirtualPrinterMQTTServer:
 
 
         except OSError as e:
         except OSError as e:
             if e.errno == 98:  # Address already in use
             if e.errno == 98:  # Address already in use
-                logger.error(f"MQTT port {self.port} is already in use")
+                logger.error("MQTT port %s is already in use", self.port)
             else:
             else:
-                logger.error(f"MQTT broker error: {e}")
+                logger.error("MQTT broker error: %s", e)
         except asyncio.CancelledError:
         except asyncio.CancelledError:
             logger.debug("MQTT broker task cancelled")
             logger.debug("MQTT broker task cancelled")
         except Exception as e:
         except Exception as e:
-            logger.error(f"MQTT broker error: {e}")
+            logger.error("MQTT broker error: %s", e)
         finally:
         finally:
             await self.stop()
             await self.stop()
 
 
@@ -133,10 +133,10 @@ class VirtualPrinterMQTTServer:
 
 
         # Bambu slicers use 'bblp' as username and access code as password
         # Bambu slicers use 'bblp' as username and access code as password
         if username == "bblp" and password == self.access_code:
         if username == "bblp" and password == self.access_code:
-            logger.debug(f"MQTT client authenticated from {session.remote_address}")
+            logger.debug("MQTT client authenticated from %s", session.remote_address)
             return True
             return True
 
 
-        logger.warning(f"MQTT auth failed for user '{username}' from {session.remote_address}")
+        logger.warning("MQTT auth failed for user '%s' from %s", username, session.remote_address)
         return False
         return False
 
 
     async def stop(self) -> None:
     async def stop(self) -> None:
@@ -147,8 +147,8 @@ class VirtualPrinterMQTTServer:
         if self._broker:
         if self._broker:
             try:
             try:
                 await self._broker.shutdown()
                 await self._broker.shutdown()
-            except Exception as e:
-                logger.debug(f"Error shutting down MQTT broker: {e}")
+            except OSError as e:
+                logger.debug("Error shutting down MQTT broker: %s", e)
             self._broker = None
             self._broker = None
 
 
 
 
@@ -186,7 +186,7 @@ class SimpleMQTTServer:
         if self._running:
         if self._running:
             return
             return
 
 
-        logger.info(f"Starting simple MQTT server on port {self.port}")
+        logger.info("Starting simple MQTT server on port %s", self.port)
 
 
         # Create SSL context with Bambu-compatible settings
         # Create SSL context with Bambu-compatible settings
         ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
         ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
@@ -208,11 +208,11 @@ class SimpleMQTTServer:
                 text=True,
                 text=True,
                 timeout=5,
                 timeout=5,
             )
             )
-            logger.info(f"MQTT SSL cert info: {result.stdout.strip()}")
-        except Exception:
+            logger.info("MQTT SSL cert info: %s", result.stdout.strip())
+        except (OSError, subprocess.SubprocessError):
             pass
             pass
 
 
-        logger.info(f"MQTT SSL context: TLS 1.2+, cert={self.cert_path}")
+        logger.info("MQTT SSL context: TLS 1.2+, cert=%s", self.cert_path)
 
 
         try:
         try:
             self._running = True
             self._running = True
@@ -227,12 +227,12 @@ class SimpleMQTTServer:
                             f"MQTT TLS connection from {addr} - cipher={ssl_obj.cipher()}, version={ssl_obj.version()}"
                             f"MQTT TLS connection from {addr} - cipher={ssl_obj.cipher()}, version={ssl_obj.version()}"
                         )
                         )
                     else:
                     else:
-                        logger.info(f"MQTT connection from {addr} (no TLS?)")
+                        logger.info("MQTT connection from %s (no TLS?)", addr)
                     await self._handle_client(reader, writer)
                     await self._handle_client(reader, writer)
                 except ssl.SSLError as e:
                 except ssl.SSLError as e:
-                    logger.error(f"MQTT SSL error: {e}")
+                    logger.error("MQTT SSL error: %s", e)
                 except Exception as e:
                 except Exception as e:
-                    logger.error(f"MQTT connection handler error: {e}")
+                    logger.error("MQTT connection handler error: %s", e)
 
 
             # Custom protocol factory to log raw connection attempts
             # Custom protocol factory to log raw connection attempts
             logger.info("Setting up MQTT server with SSL error handling...")
             logger.info("Setting up MQTT server with SSL error handling...")
@@ -242,9 +242,9 @@ class SimpleMQTTServer:
                 exception = context.get("exception")
                 exception = context.get("exception")
                 message = context.get("message", "")
                 message = context.get("message", "")
                 if "ssl" in str(exception).lower() or "ssl" in message.lower():
                 if "ssl" in str(exception).lower() or "ssl" in message.lower():
-                    logger.error(f"SSL error: {message} - {exception}")
+                    logger.error("SSL error: %s - %s", message, exception)
                 else:
                 else:
-                    logger.debug(f"Asyncio error: {message}")
+                    logger.debug("Asyncio error: %s", message)
 
 
             asyncio.get_event_loop().set_exception_handler(handle_ssl_error)
             asyncio.get_event_loop().set_exception_handler(handle_ssl_error)
 
 
@@ -255,7 +255,7 @@ class SimpleMQTTServer:
                 ssl=ssl_context,
                 ssl=ssl_context,
             )
             )
 
 
-            logger.info(f"Simple MQTT server listening on port {self.port}")
+            logger.info("Simple MQTT server listening on port %s", self.port)
 
 
             # Start periodic status push task
             # Start periodic status push task
             self._status_push_task = asyncio.create_task(self._periodic_status_push())
             self._status_push_task = asyncio.create_task(self._periodic_status_push())
@@ -265,13 +265,13 @@ class SimpleMQTTServer:
 
 
         except OSError as e:
         except OSError as e:
             if e.errno == 98:  # Address already in use
             if e.errno == 98:  # Address already in use
-                logger.error(f"MQTT port {self.port} is already in use")
+                logger.error("MQTT port %s is already in use", self.port)
             else:
             else:
-                logger.error(f"MQTT server error: {e}")
+                logger.error("MQTT server error: %s", e)
         except asyncio.CancelledError:
         except asyncio.CancelledError:
             logger.debug("MQTT server task cancelled")
             logger.debug("MQTT server task cancelled")
         except Exception as e:
         except Exception as e:
-            logger.error(f"MQTT server error: {e}")
+            logger.error("MQTT server error: %s", e)
         finally:
         finally:
             await self.stop()
             await self.stop()
 
 
@@ -294,7 +294,7 @@ class SimpleMQTTServer:
             try:
             try:
                 writer.close()
                 writer.close()
                 await writer.wait_closed()
                 await writer.wait_closed()
-            except Exception:
+            except OSError:
                 pass
                 pass
         self._clients.clear()
         self._clients.clear()
 
 
@@ -302,7 +302,7 @@ class SimpleMQTTServer:
             try:
             try:
                 self._server.close()
                 self._server.close()
                 await self._server.wait_closed()
                 await self._server.wait_closed()
-            except Exception:
+            except OSError:
                 pass
                 pass
             self._server = None
             self._server = None
 
 
@@ -321,8 +321,8 @@ class SimpleMQTTServer:
                             disconnected.append(client_id)
                             disconnected.append(client_id)
                             continue
                             continue
                         await self._send_status_report(writer)
                         await self._send_status_report(writer)
-                    except Exception as e:
-                        logger.debug(f"Failed to push status to {client_id}: {e}")
+                    except OSError as e:
+                        logger.debug("Failed to push status to %s: %s", client_id, e)
                         disconnected.append(client_id)
                         disconnected.append(client_id)
 
 
                 # Remove disconnected clients
                 # Remove disconnected clients
@@ -332,7 +332,7 @@ class SimpleMQTTServer:
             except asyncio.CancelledError:
             except asyncio.CancelledError:
                 break
                 break
             except Exception as e:
             except Exception as e:
-                logger.error(f"Periodic status push error: {e}")
+                logger.error("Periodic status push error: %s", e)
 
 
         logger.info("Periodic status push task stopped")
         logger.info("Periodic status push task stopped")
 
 
@@ -340,7 +340,7 @@ class SimpleMQTTServer:
         """Handle an MQTT client connection."""
         """Handle an MQTT client connection."""
         addr = writer.get_extra_info("peername")
         addr = writer.get_extra_info("peername")
         client_id = f"{addr[0]}:{addr[1]}" if addr else "unknown"
         client_id = f"{addr[0]}:{addr[1]}" if addr else "unknown"
-        logger.info(f"MQTT client connected: {client_id}")
+        logger.info("MQTT client connected: %s", client_id)
 
 
         authenticated = False
         authenticated = False
 
 
@@ -388,15 +388,15 @@ class SimpleMQTTServer:
         except asyncio.CancelledError:
         except asyncio.CancelledError:
             pass
             pass
         except Exception as e:
         except Exception as e:
-            logger.debug(f"MQTT client error: {e}")
+            logger.debug("MQTT client error: %s", e)
         finally:
         finally:
-            logger.debug(f"MQTT client disconnected: {client_id}")
+            logger.debug("MQTT client disconnected: %s", client_id)
             if client_id in self._clients:
             if client_id in self._clients:
                 del self._clients[client_id]
                 del self._clients[client_id]
             try:
             try:
                 writer.close()
                 writer.close()
                 await writer.wait_closed()
                 await writer.wait_closed()
-            except Exception:
+            except OSError:
                 pass
                 pass
 
 
     async def _read_remaining_length(self, reader: asyncio.StreamReader) -> int | None:
     async def _read_remaining_length(self, reader: asyncio.StreamReader) -> int | None:
@@ -414,7 +414,7 @@ class SimpleMQTTServer:
                 if (encoded & 128) == 0:
                 if (encoded & 128) == 0:
                     return value
                     return value
                 multiplier *= 128
                 multiplier *= 128
-            except Exception:
+            except OSError:
                 return None
                 return None
 
 
         return None
         return None
@@ -469,11 +469,11 @@ class SimpleMQTTServer:
                 # Send CONNACK with auth failure
                 # Send CONNACK with auth failure
                 writer.write(bytes([0x20, 0x02, 0x00, 0x05]))  # Not authorized
                 writer.write(bytes([0x20, 0x02, 0x00, 0x05]))  # Not authorized
                 await writer.drain()
                 await writer.drain()
-                logger.warning(f"MQTT auth failed for user '{username}'")
+                logger.warning("MQTT auth failed for user '%s'", username)
                 return False
                 return False
 
 
-        except Exception as e:
-            logger.debug(f"MQTT CONNECT parse error: {e}")
+        except (IndexError, ValueError) as e:
+            logger.debug("MQTT CONNECT parse error: %s", e)
             # Send CONNACK with error
             # Send CONNACK with error
             writer.write(bytes([0x20, 0x02, 0x00, 0x02]))  # Protocol error
             writer.write(bytes([0x20, 0x02, 0x00, 0x02]))  # Protocol error
             await writer.drain()
             await writer.drain()
@@ -496,7 +496,7 @@ class SimpleMQTTServer:
                 requested_qos = payload[idx]
                 requested_qos = payload[idx]
                 idx += 1
                 idx += 1
 
 
-                logger.info(f"MQTT subscribe: {topic} QoS={requested_qos}")
+                logger.info("MQTT subscribe: %s QoS=%s", topic, requested_qos)
                 granted_qos.append(min(requested_qos, 1))  # Grant up to QoS 1
                 granted_qos.append(min(requested_qos, 1))  # Grant up to QoS 1
 
 
             # Send SUBACK
             # Send SUBACK
@@ -508,8 +508,8 @@ class SimpleMQTTServer:
             # Send initial status report after subscribe
             # Send initial status report after subscribe
             await self._send_status_report(writer)
             await self._send_status_report(writer)
 
 
-        except Exception as e:
-            logger.debug(f"MQTT SUBSCRIBE error: {e}")
+        except (IndexError, ValueError, OSError) as e:
+            logger.debug("MQTT SUBSCRIBE error: %s", e)
 
 
     async def _send_status_report(self, writer: asyncio.StreamWriter) -> None:
     async def _send_status_report(self, writer: asyncio.StreamWriter) -> None:
         """Send a status report to the slicer after connection."""
         """Send a status report to the slicer after connection."""
@@ -620,10 +620,10 @@ class SimpleMQTTServer:
             writer.write(packet)
             writer.write(packet)
             await writer.drain()
             await writer.drain()
 
 
-            logger.info(f"Sent initial status report on {topic}")
+            logger.info("Sent initial status report on %s", topic)
 
 
-        except Exception as e:
-            logger.error(f"Failed to send status report: {e}")
+        except OSError as e:
+            logger.error("Failed to send status report: %s", e)
 
 
     async def _send_version_response(self, writer: asyncio.StreamWriter, sequence_id: str) -> None:
     async def _send_version_response(self, writer: asyncio.StreamWriter, sequence_id: str) -> None:
         """Send version info response to the slicer."""
         """Send version info response to the slicer."""
@@ -715,10 +715,10 @@ class SimpleMQTTServer:
             writer.write(packet)
             writer.write(packet)
             await writer.drain()
             await writer.drain()
 
 
-            logger.info(f"Sent version response on {topic}")
+            logger.info("Sent version response on %s", topic)
 
 
-        except Exception as e:
-            logger.error(f"Failed to send version response: {e}")
+        except OSError as e:
+            logger.error("Failed to send version response: %s", e)
 
 
     async def _handle_publish(self, header: int, payload: bytes, writer: asyncio.StreamWriter) -> None:
     async def _handle_publish(self, header: int, payload: bytes, writer: asyncio.StreamWriter) -> None:
         """Handle MQTT PUBLISH packet."""
         """Handle MQTT PUBLISH packet."""
@@ -739,7 +739,7 @@ class SimpleMQTTServer:
             # Parse message
             # Parse message
             message = payload[idx:].decode("utf-8")
             message = payload[idx:].decode("utf-8")
 
 
-            logger.info(f"MQTT publish to {topic}: {message[:100]}...")
+            logger.info("MQTT publish to %s: %s...", topic, message[:100])
 
 
             # Handle commands on device request topic
             # Handle commands on device request topic
             if f"device/{self.serial}/request" in topic:
             if f"device/{self.serial}/request" in topic:
@@ -750,7 +750,7 @@ class SimpleMQTTServer:
                     if "pushing" in data:
                     if "pushing" in data:
                         pushing_data = data["pushing"]
                         pushing_data = data["pushing"]
                         command = pushing_data.get("command", "")
                         command = pushing_data.get("command", "")
-                        logger.info(f"MQTT pushing command: {command}")
+                        logger.info("MQTT pushing command: %s", command)
 
 
                         if command == "pushall":
                         if command == "pushall":
                             # Slicer is requesting full status - send response
                             # Slicer is requesting full status - send response
@@ -766,7 +766,7 @@ class SimpleMQTTServer:
                         info_data = data["info"]
                         info_data = data["info"]
                         command = info_data.get("command", "")
                         command = info_data.get("command", "")
                         sequence_id = info_data.get("sequence_id", "0")
                         sequence_id = info_data.get("sequence_id", "0")
-                        logger.info(f"MQTT info command: {command}")
+                        logger.info("MQTT info command: %s", command)
 
 
                         if command == "get_version":
                         if command == "get_version":
                             await self._send_version_response(writer, sequence_id)
                             await self._send_version_response(writer, sequence_id)
@@ -777,7 +777,7 @@ class SimpleMQTTServer:
                         command = print_data.get("command", "")
                         command = print_data.get("command", "")
                         filename = print_data.get("subtask_name", "")
                         filename = print_data.get("subtask_name", "")
 
 
-                        logger.info(f"MQTT print command: {command} for {filename}")
+                        logger.info("MQTT print command: %s for %s", command, filename)
 
 
                         if self.on_print_command and command == "project_file":
                         if self.on_print_command and command == "project_file":
                             await self._notify_print_command(filename, print_data)
                             await self._notify_print_command(filename, print_data)
@@ -785,8 +785,8 @@ class SimpleMQTTServer:
                 except json.JSONDecodeError:
                 except json.JSONDecodeError:
                     pass
                     pass
 
 
-        except Exception as e:
-            logger.debug(f"MQTT PUBLISH error: {e}")
+        except (IndexError, ValueError, OSError) as e:
+            logger.debug("MQTT PUBLISH error: %s", e)
 
 
     async def _notify_print_command(self, filename: str, data: dict) -> None:
     async def _notify_print_command(self, filename: str, data: dict) -> None:
         """Notify callback of print command."""
         """Notify callback of print command."""
@@ -796,4 +796,4 @@ class SimpleMQTTServer:
                 if asyncio.iscoroutine(result):
                 if asyncio.iscoroutine(result):
                     await result
                     await result
             except Exception as e:
             except Exception as e:
-                logger.error(f"Print command callback error: {e}")
+                logger.error("Print command callback error: %s", e)

+ 36 - 34
backend/app/services/virtual_printer/ssdp_server.py

@@ -61,7 +61,7 @@ class VirtualPrinterSSDPServer:
             s.close()
             s.close()
             self._local_ip = ip
             self._local_ip = ip
             return ip
             return ip
-        except Exception:
+        except OSError:
             return "127.0.0.1"
             return "127.0.0.1"
 
 
     def _build_notify_message(self) -> bytes:
     def _build_notify_message(self) -> bytes:
@@ -128,7 +128,7 @@ class VirtualPrinterSSDPServer:
         if self._running:
         if self._running:
             return
             return
 
 
-        logger.info(f"Starting virtual printer SSDP server: {self.name} ({self.serial})")
+        logger.info("Starting virtual printer SSDP server: %s (%s)", self.name, self.serial)
         self._running = True
         self._running = True
 
 
         try:
         try:
@@ -159,8 +159,8 @@ class VirtualPrinterSSDPServer:
             self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
             self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
 
 
             local_ip = self._get_local_ip()
             local_ip = self._get_local_ip()
-            logger.info(f"SSDP server listening on port {SSDP_PORT}, advertising IP: {local_ip}")
-            logger.info(f"Virtual printer: {self.name} ({self.serial}) model={self.model}")
+            logger.info("SSDP server listening on port %s, advertising IP: %s", SSDP_PORT, local_ip)
+            logger.info("Virtual printer: %s (%s) model=%s", self.name, self.serial, self.model)
 
 
             # Send initial NOTIFY
             # Send initial NOTIFY
             await self._send_notify()
             await self._send_notify()
@@ -178,9 +178,9 @@ class VirtualPrinterSSDPServer:
                     await self._handle_message(message, addr)
                     await self._handle_message(message, addr)
                 except BlockingIOError:
                 except BlockingIOError:
                     pass
                     pass
-                except Exception as e:
+                except OSError as e:
                     if self._running:
                     if self._running:
-                        logger.debug(f"SSDP receive error: {e}")
+                        logger.debug("SSDP receive error: %s", e)
 
 
                 # Send periodic NOTIFY
                 # Send periodic NOTIFY
                 now = asyncio.get_event_loop().time()
                 now = asyncio.get_event_loop().time()
@@ -192,13 +192,13 @@ class VirtualPrinterSSDPServer:
 
 
         except OSError as e:
         except OSError as e:
             if e.errno == 98:  # Address already in use
             if e.errno == 98:  # Address already in use
-                logger.warning(f"SSDP port {SSDP_PORT} in use - real printers may be running")
+                logger.warning("SSDP port %s in use - real printers may be running", SSDP_PORT)
             else:
             else:
-                logger.error(f"SSDP server error: {e}")
+                logger.error("SSDP server error: %s", e)
         except asyncio.CancelledError:
         except asyncio.CancelledError:
             logger.debug("SSDP server cancelled")
             logger.debug("SSDP server cancelled")
         except Exception as e:
         except Exception as e:
-            logger.error(f"SSDP server error: {e}")
+            logger.error("SSDP server error: %s", e)
         finally:
         finally:
             await self._cleanup()
             await self._cleanup()
 
 
@@ -214,12 +214,12 @@ class VirtualPrinterSSDPServer:
             try:
             try:
                 # Send byebye message
                 # Send byebye message
                 await self._send_byebye()
                 await self._send_byebye()
-            except Exception:
+            except OSError:
                 pass
                 pass
 
 
             try:
             try:
                 self._socket.close()
                 self._socket.close()
-            except Exception:
+            except OSError:
                 pass
                 pass
             self._socket = None
             self._socket = None
 
 
@@ -232,9 +232,9 @@ class VirtualPrinterSSDPServer:
             msg = self._build_notify_message()
             msg = self._build_notify_message()
             # Real Bambu printers broadcast to 255.255.255.255, not multicast
             # Real Bambu printers broadcast to 255.255.255.255, not multicast
             self._socket.sendto(msg, (SSDP_BROADCAST_ADDR, SSDP_PORT))
             self._socket.sendto(msg, (SSDP_BROADCAST_ADDR, SSDP_PORT))
-            logger.debug(f"Sent SSDP NOTIFY for {self.name}")
-        except Exception as e:
-            logger.debug(f"Failed to send NOTIFY: {e}")
+            logger.debug("Sent SSDP NOTIFY for %s", self.name)
+        except OSError as e:
+            logger.debug("Failed to send NOTIFY: %s", e)
 
 
     async def _send_byebye(self) -> None:
     async def _send_byebye(self) -> None:
         """Send SSDP byebye message when shutting down."""
         """Send SSDP byebye message when shutting down."""
@@ -253,7 +253,7 @@ class VirtualPrinterSSDPServer:
         try:
         try:
             self._socket.sendto(message.encode(), (SSDP_BROADCAST_ADDR, SSDP_PORT))
             self._socket.sendto(message.encode(), (SSDP_BROADCAST_ADDR, SSDP_PORT))
             logger.debug("Sent SSDP byebye")
             logger.debug("Sent SSDP byebye")
-        except Exception:
+        except OSError:
             pass
             pass
 
 
     async def _handle_message(self, message: str, addr: tuple[str, int]) -> None:
     async def _handle_message(self, message: str, addr: tuple[str, int]) -> None:
@@ -271,16 +271,16 @@ class VirtualPrinterSSDPServer:
         if BAMBU_SEARCH_TARGET not in message and "ssdp:all" not in message.lower():
         if BAMBU_SEARCH_TARGET not in message and "ssdp:all" not in message.lower():
             return
             return
 
 
-        logger.debug(f"Received M-SEARCH from {addr[0]}")
+        logger.debug("Received M-SEARCH from %s", addr[0])
 
 
         # Send response
         # Send response
         if self._socket:
         if self._socket:
             try:
             try:
                 response = self._build_response_message()
                 response = self._build_response_message()
                 self._socket.sendto(response, addr)
                 self._socket.sendto(response, addr)
-                logger.info(f"Sent SSDP response to {addr[0]} for virtual printer '{self.name}'")
-            except Exception as e:
-                logger.debug(f"Failed to send SSDP response: {e}")
+                logger.info("Sent SSDP response to %s for virtual printer '%s'", addr[0], self.name)
+            except OSError as e:
+                logger.debug("Failed to send SSDP response: %s", e)
 
 
 
 
 class SSDPProxy:
 class SSDPProxy:
@@ -341,13 +341,13 @@ class SSDPProxy:
                 flags=re.IGNORECASE,
                 flags=re.IGNORECASE,
             )
             )
             if text != original:
             if text != original:
-                logger.debug(f"Rewrote SSDP Location to {self.remote_interface_ip}")
-                logger.debug(f"Rewritten SSDP packet:\n{text}")
+                logger.debug("Rewrote SSDP Location to %s", self.remote_interface_ip)
+                logger.debug("Rewritten SSDP packet:\n%s", text)
             else:
             else:
-                logger.warning(f"SSDP Location rewrite had no effect. Packet:\n{original}")
+                logger.warning("SSDP Location rewrite had no effect. Packet:\n%s", original)
             return text.encode("utf-8")
             return text.encode("utf-8")
         except Exception as e:
         except Exception as e:
-            logger.error(f"Failed to rewrite SSDP: {e}")
+            logger.error("Failed to rewrite SSDP: %s", e)
             return data
             return data
 
 
     async def start(self) -> None:
     async def start(self) -> None:
@@ -397,8 +397,10 @@ class SSDPProxy:
             self._remote_socket.bind((self.remote_interface_ip, 0))
             self._remote_socket.bind((self.remote_interface_ip, 0))
             self._remote_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
             self._remote_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
 
 
-            logger.info(f"SSDP proxy listening on 0.0.0.0:{SSDP_PORT} (filtering for printer {self.target_printer_ip})")
-            logger.info(f"SSDP proxy will broadcast on {self.remote_interface_ip}")
+            logger.info(
+                "SSDP proxy listening on 0.0.0.0:%s (filtering for printer %s)", SSDP_PORT, self.target_printer_ip
+            )
+            logger.info("SSDP proxy will broadcast on %s", self.remote_interface_ip)
 
 
             # Main loop
             # Main loop
             last_broadcast = 0.0
             last_broadcast = 0.0
@@ -411,9 +413,9 @@ class SSDPProxy:
                     await self._handle_local_packet(data, addr)
                     await self._handle_local_packet(data, addr)
                 except BlockingIOError:
                 except BlockingIOError:
                     pass
                     pass
-                except Exception as e:
+                except OSError as e:
                     if self._running:
                     if self._running:
-                        logger.debug(f"SSDP proxy receive error: {e}")
+                        logger.debug("SSDP proxy receive error: %s", e)
 
 
                 # Listen for M-SEARCH from slicer on LAN B (via remote socket would need separate bind)
                 # Listen for M-SEARCH from slicer on LAN B (via remote socket would need separate bind)
                 # For now, we periodically re-broadcast cached printer SSDP
                 # For now, we periodically re-broadcast cached printer SSDP
@@ -425,11 +427,11 @@ class SSDPProxy:
                 await asyncio.sleep(0.1)
                 await asyncio.sleep(0.1)
 
 
         except OSError as e:
         except OSError as e:
-            logger.error(f"SSDP proxy error: {e}")
+            logger.error("SSDP proxy error: %s", e)
         except asyncio.CancelledError:
         except asyncio.CancelledError:
             logger.debug("SSDP proxy cancelled")
             logger.debug("SSDP proxy cancelled")
         except Exception as e:
         except Exception as e:
-            logger.error(f"SSDP proxy error: {e}")
+            logger.error("SSDP proxy error: %s", e)
         finally:
         finally:
             await self._cleanup()
             await self._cleanup()
 
 
@@ -445,7 +447,7 @@ class SSDPProxy:
             if sock:
             if sock:
                 try:
                 try:
                     sock.close()
                     sock.close()
-                except Exception:
+                except OSError:
                     pass
                     pass
         self._local_socket = None
         self._local_socket = None
         self._remote_socket = None
         self._remote_socket = None
@@ -470,7 +472,7 @@ class SSDPProxy:
         headers = self._parse_ssdp_message(data)
         headers = self._parse_ssdp_message(data)
         if headers:
         if headers:
             self._printer_info = headers
             self._printer_info = headers
-            logger.debug(f"Received SSDP from printer {sender_ip}: {headers.get('devname.bambu.com', 'unknown')}")
+            logger.debug("Received SSDP from printer %s: %s", sender_ip, headers.get("devname.bambu.com", "unknown"))
 
 
         # Store and immediately broadcast
         # Store and immediately broadcast
         self._last_printer_ssdp = data
         self._last_printer_ssdp = data
@@ -490,6 +492,6 @@ class SSDPProxy:
             self._remote_socket.sendto(rewritten, (SSDP_BROADCAST_ADDR, SSDP_PORT))
             self._remote_socket.sendto(rewritten, (SSDP_BROADCAST_ADDR, SSDP_PORT))
 
 
             printer_name = self._printer_info.get("devname.bambu.com", "unknown")
             printer_name = self._printer_info.get("devname.bambu.com", "unknown")
-            logger.debug(f"Broadcast SSDP for '{printer_name}' on LAN B ({self.remote_interface_ip})")
-        except Exception as e:
-            logger.debug(f"Failed to broadcast SSDP on remote: {e}")
+            logger.debug("Broadcast SSDP for '%s' on LAN B (%s)", printer_name, self.remote_interface_ip)
+        except OSError as e:
+            logger.debug("Failed to broadcast SSDP on remote: %s", e)

+ 28 - 26
backend/app/services/virtual_printer/tcp_proxy.py

@@ -107,26 +107,26 @@ class TLSProxy:
                 ssl=self._server_ssl_context,
                 ssl=self._server_ssl_context,
             )
             )
 
 
-            logger.info(f"{self.name} TLS proxy listening on port {self.listen_port}")
+            logger.info("%s TLS proxy listening on port %s", self.name, self.listen_port)
 
 
             async with self._server:
             async with self._server:
                 await self._server.serve_forever()
                 await self._server.serve_forever()
 
 
         except OSError as e:
         except OSError as e:
             if e.errno == 98:  # Address already in use
             if e.errno == 98:  # Address already in use
-                logger.error(f"{self.name} proxy port {self.listen_port} is already in use")
+                logger.error("%s proxy port %s is already in use", self.name, self.listen_port)
             else:
             else:
-                logger.error(f"{self.name} proxy error: {e}")
+                logger.error("%s proxy error: %s", self.name, e)
         except asyncio.CancelledError:
         except asyncio.CancelledError:
-            logger.debug(f"{self.name} proxy task cancelled")
+            logger.debug("%s proxy task cancelled", self.name)
         except Exception as e:
         except Exception as e:
-            logger.error(f"{self.name} proxy error: {e}")
+            logger.error("%s proxy error: %s", self.name, e)
         finally:
         finally:
             await self.stop()
             await self.stop()
 
 
     async def stop(self) -> None:
     async def stop(self) -> None:
         """Stop the TLS proxy server."""
         """Stop the TLS proxy server."""
-        logger.info(f"Stopping {self.name} proxy")
+        logger.info("Stopping %s proxy", self.name)
         self._running = False
         self._running = False
 
 
         # Cancel all active connection tasks
         # Cancel all active connection tasks
@@ -145,8 +145,8 @@ class TLSProxy:
             try:
             try:
                 self._server.close()
                 self._server.close()
                 await self._server.wait_closed()
                 await self._server.wait_closed()
-            except Exception as e:
-                logger.debug(f"Error closing {self.name} proxy server: {e}")
+            except OSError as e:
+                logger.debug("Error closing %s proxy server: %s", self.name, e)
             self._server = None
             self._server = None
 
 
     async def _handle_client(
     async def _handle_client(
@@ -158,7 +158,7 @@ class TLSProxy:
         peername = client_writer.get_extra_info("peername")
         peername = client_writer.get_extra_info("peername")
         client_id = f"{peername[0]}:{peername[1]}" if peername else "unknown"
         client_id = f"{peername[0]}:{peername[1]}" if peername else "unknown"
 
 
-        logger.info(f"{self.name} proxy: client connected from {client_id}")
+        logger.info("%s proxy: client connected from %s", self.name, client_id)
 
 
         if self.on_connect:
         if self.on_connect:
             try:
             try:
@@ -176,19 +176,21 @@ class TLSProxy:
                 ),
                 ),
                 timeout=10.0,
                 timeout=10.0,
             )
             )
-            logger.info(f"{self.name} proxy: connected to printer {self.target_host}:{self.target_port}")
+            logger.info("%s proxy: connected to printer %s:%s", self.name, self.target_host, self.target_port)
         except TimeoutError:
         except TimeoutError:
-            logger.error(f"{self.name} proxy: timeout connecting to {self.target_host}:{self.target_port}")
+            logger.error("%s proxy: timeout connecting to %s:%s", self.name, self.target_host, self.target_port)
             client_writer.close()
             client_writer.close()
             await client_writer.wait_closed()
             await client_writer.wait_closed()
             return
             return
         except ssl.SSLError as e:
         except ssl.SSLError as e:
-            logger.error(f"{self.name} proxy: SSL error connecting to {self.target_host}:{self.target_port}: {e}")
+            logger.error(
+                "%s proxy: SSL error connecting to %s:%s: %s", self.name, self.target_host, self.target_port, e
+            )
             client_writer.close()
             client_writer.close()
             await client_writer.wait_closed()
             await client_writer.wait_closed()
             return
             return
-        except Exception as e:
-            logger.error(f"{self.name} proxy: failed to connect to {self.target_host}:{self.target_port}: {e}")
+        except OSError as e:
+            logger.error("%s proxy: failed to connect to %s:%s: %s", self.name, self.target_host, self.target_port, e)
             client_writer.close()
             client_writer.close()
             await client_writer.wait_closed()
             await client_writer.wait_closed()
             return
             return
@@ -221,7 +223,7 @@ class TLSProxy:
                     pass
                     pass
 
 
         except Exception as e:
         except Exception as e:
-            logger.debug(f"{self.name} proxy connection error: {e}")
+            logger.debug("%s proxy connection error: %s", self.name, e)
         finally:
         finally:
             # Clean up
             # Clean up
             self._active_connections.pop(client_id, None)
             self._active_connections.pop(client_id, None)
@@ -230,10 +232,10 @@ class TLSProxy:
                 try:
                 try:
                     writer.close()
                     writer.close()
                     await writer.wait_closed()
                     await writer.wait_closed()
-                except Exception:
+                except OSError:
                     pass
                     pass
 
 
-            logger.info(f"{self.name} proxy: client {client_id} disconnected")
+            logger.info("%s proxy: client %s disconnected", self.name, client_id)
 
 
             if self.on_disconnect:
             if self.on_disconnect:
                 try:
                 try:
@@ -268,18 +270,18 @@ class TLSProxy:
                 await writer.drain()
                 await writer.drain()
 
 
                 total_bytes += len(data)
                 total_bytes += len(data)
-                logger.debug(f"{self.name} proxy {direction}: {len(data)} bytes")
+                logger.debug("%s proxy %s: %s bytes", self.name, direction, len(data))
 
 
         except asyncio.CancelledError:
         except asyncio.CancelledError:
             pass
             pass
         except ConnectionResetError:
         except ConnectionResetError:
-            logger.debug(f"{self.name} proxy {direction}: connection reset")
+            logger.debug("%s proxy %s: connection reset", self.name, direction)
         except BrokenPipeError:
         except BrokenPipeError:
-            logger.debug(f"{self.name} proxy {direction}: broken pipe")
-        except Exception as e:
-            logger.debug(f"{self.name} proxy {direction} error: {e}")
+            logger.debug("%s proxy %s: broken pipe", self.name, direction)
+        except OSError as e:
+            logger.debug("%s proxy %s error: %s", self.name, direction, e)
 
 
-        logger.debug(f"{self.name} proxy {direction}: total {total_bytes} bytes")
+        logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
 
 
 
 
 class SlicerProxyManager:
 class SlicerProxyManager:
@@ -320,7 +322,7 @@ class SlicerProxyManager:
 
 
     async def start(self) -> None:
     async def start(self) -> None:
         """Start FTP and MQTT TLS proxies."""
         """Start FTP and MQTT TLS proxies."""
-        logger.info(f"Starting slicer TLS proxy to {self.target_host}")
+        logger.info("Starting slicer TLS proxy to %s", self.target_host)
 
 
         # Create proxies with TLS
         # Create proxies with TLS
         self._ftp_proxy = TLSProxy(
         self._ftp_proxy = TLSProxy(
@@ -350,7 +352,7 @@ class SlicerProxyManager:
             try:
             try:
                 await proxy.start()
                 await proxy.start()
             except Exception as e:
             except Exception as e:
-                logger.error(f"Slicer proxy {proxy.name} failed: {e}")
+                logger.error("Slicer proxy %s failed: %s", proxy.name, e)
 
 
         self._tasks = [
         self._tasks = [
             asyncio.create_task(
             asyncio.create_task(
@@ -363,7 +365,7 @@ class SlicerProxyManager:
             ),
             ),
         ]
         ]
 
 
-        logger.info(f"Slicer TLS proxy started for {self.target_host}")
+        logger.info("Slicer TLS proxy started for %s", self.target_host)
 
 
         # Wait for tasks to complete (they run until cancelled)
         # Wait for tasks to complete (they run until cancelled)
         # This keeps the start() coroutine alive so the parent task doesn't complete
         # This keeps the start() coroutine alive so the parent task doesn't complete

+ 4 - 3
backend/app/utils/threemf_tools.py

@@ -12,6 +12,7 @@ import zipfile
 from pathlib import Path
 from pathlib import Path
 
 
 import defusedxml.ElementTree as ET
 import defusedxml.ElementTree as ET
+from defusedxml.ElementTree import ParseError as XMLParseError
 
 
 # Default filament properties
 # Default filament properties
 DEFAULT_FILAMENT_DIAMETER = 1.75  # mm
 DEFAULT_FILAMENT_DIAMETER = 1.75  # mm
@@ -176,7 +177,7 @@ def extract_layer_filament_usage_from_3mf(file_path: Path) -> dict[int, dict[int
             gcode_content = zf.read(gcode_path).decode("utf-8", errors="ignore")
             gcode_content = zf.read(gcode_path).decode("utf-8", errors="ignore")
 
 
             return parse_gcode_layer_filament_usage(gcode_content)
             return parse_gcode_layer_filament_usage(gcode_content)
-    except Exception:
+    except (zipfile.BadZipFile, OSError, UnicodeDecodeError):
         return None
         return None
 
 
 
 
@@ -258,7 +259,7 @@ def extract_filament_properties_from_3mf(file_path: Path) -> dict[int, dict]:
                             properties[fid]["density"] = DEFAULT_FILAMENT_DENSITY
                             properties[fid]["density"] = DEFAULT_FILAMENT_DENSITY
                 except json.JSONDecodeError:
                 except json.JSONDecodeError:
                     pass
                     pass
-    except Exception:
+    except (zipfile.BadZipFile, OSError, KeyError, ValueError, XMLParseError, UnicodeDecodeError):
         pass
         pass
 
 
     return properties
     return properties
@@ -302,7 +303,7 @@ def extract_filament_usage_from_3mf(file_path: Path) -> list[dict]:
                         )
                         )
                 except (ValueError, TypeError):
                 except (ValueError, TypeError):
                     pass
                     pass
-    except Exception:
+    except (zipfile.BadZipFile, OSError, KeyError, ValueError, XMLParseError, UnicodeDecodeError):
         pass
         pass
 
 
     return filament_usage
     return filament_usage

+ 4 - 0
requirements-dev.txt

@@ -5,3 +5,7 @@ pytest-cov>=4.1.0
 pytest-xdist>=3.5.0
 pytest-xdist>=3.5.0
 httpx>=0.27.0
 httpx>=0.27.0
 ruff>=0.8.0
 ruff>=0.8.0
+
+# Security scanning
+bandit[sarif]>=1.7.0
+pip-audit>=2.7.0

+ 401 - 0
test_security.sh

@@ -0,0 +1,401 @@
+#!/usr/bin/env bash
+#
+# Local security scanning - mirrors GitHub Actions pipeline
+# Runs all scans in parallel and shows a consolidated summary.
+#
+# Usage:
+#   ./test_security.sh              # Run fast scans (bandit, pip-audit, npm-audit)
+#   ./test_security.sh --full       # Run full pipeline (all scans below)
+#   ./test_security.sh bandit       # Run a specific scan
+#   ./test_security.sh codeql trivy # Run multiple specific scans
+#
+# Available scans:
+#   bandit          Python static security analysis (SAST)
+#   codeql          CodeQL analysis (Actions + JavaScript + Python)
+#   codeql-actions  CodeQL GitHub Actions only
+#   codeql-python   CodeQL Python only
+#   codeql-js       CodeQL JavaScript/TypeScript only
+#   trivy           Trivy container image + Dockerfile/IaC scan
+#   trivy-image     Trivy container image scan only
+#   trivy-config    Trivy Dockerfile/IaC scan only
+#   pip-audit       Python dependency vulnerability audit
+#   npm-audit       Frontend dependency vulnerability audit
+#
+# Prerequisites:
+#   pip install bandit[sarif] pip-audit     # Python tools
+#   gh extension install github/gh-codeql   # CodeQL CLI
+#   curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh  # Trivy
+#
+
+set -uo pipefail
+
+# Navigate to project root
+PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$PROJECT_ROOT"
+
+# Colors
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+CYAN='\033[0;36m'
+BOLD='\033[1m'
+DIM='\033[2m'
+NC='\033[0m'
+
+# ── Temp directory for scan output ───────────────────────────────────────
+
+WORK_DIR=$(mktemp -d)
+trap 'rm -rf "$WORK_DIR"' EXIT
+
+# Parallel job tracking
+declare -A PIDS=()       # scan_name -> PID
+declare -A RESULTS=()    # scan_name -> PASS|FAIL|SKIP
+declare -A DURATIONS=()  # scan_name -> seconds
+
+# Scan display order
+SCAN_ORDER=()
+
+# ── SARIF parser (used for CodeQL result display) ────────────────────────
+
+parse_sarif() {
+    local sarif_file="$1"
+    python3 << PYEOF
+import json
+from collections import defaultdict
+
+with open("$sarif_file") as f:
+    data = json.load(f)
+
+rule_desc = {}
+for run in data.get("runs", []):
+    for rule in run.get("tool", {}).get("driver", {}).get("rules", []):
+        rid = rule.get("id", "")
+        desc = rule.get("shortDescription", {}).get("text", "")
+        rule_desc[rid] = desc
+
+by_rule = defaultdict(list)
+for run in data.get("runs", []):
+    for result in run.get("results", []):
+        rule_id = result.get("ruleId", "unknown")
+        msg = result.get("message", {}).get("text", "")
+        locs = result.get("locations", [])
+        loc = ""
+        if locs:
+            pl = locs[0].get("physicalLocation", {})
+            uri = pl.get("artifactLocation", {}).get("uri", "")
+            line = pl.get("region", {}).get("startLine", "")
+            loc = f"{uri}:{line}" if line else uri
+        by_rule[rule_id].append((loc, msg))
+
+total = sum(len(v) for v in by_rule.values())
+if total == 0:
+    print("No findings.")
+else:
+    print(f"{total} findings:")
+    print()
+    for rule_id, findings in sorted(by_rule.items(), key=lambda x: -len(x[1])):
+        desc = rule_desc.get(rule_id, "")
+        print(f"  {rule_id} ({len(findings)}) -- {desc}")
+        for loc, msg in findings:
+            short_msg = msg[:100] + "..." if len(msg) > 100 else msg
+            print(f"    {loc:60s} {short_msg}")
+        print()
+PYEOF
+}
+
+# ── Scan functions (write to stdout, return exit code) ───────────────────
+
+check_command() {
+    command -v "$1" &>/dev/null
+}
+
+has_codeql() {
+    check_command gh && gh codeql version &>/dev/null
+}
+
+scan_bandit() {
+    if ! check_command bandit; then
+        echo "SKIP: 'bandit' not found. Install: pip install bandit[sarif]"
+        return 2
+    fi
+    bandit -r backend/ --severity-level medium 2>&1
+}
+
+scan_codeql_python() {
+    local sarif="$PROJECT_ROOT/codeql-python-results.sarif"
+    if ! has_codeql; then
+        echo "SKIP: CodeQL CLI not installed. Install: gh extension install github/gh-codeql"
+        return 2
+    fi
+    echo "Creating database..."
+    gh codeql database create --overwrite --language=python /tmp/bambuddy-codeql-python &>/dev/null
+    echo "Analyzing..."
+    gh codeql database analyze /tmp/bambuddy-codeql-python \
+        codeql/python-queries:codeql-suites/python-security-and-quality.qls \
+        --format=sarifv2.1.0 --output="$sarif" &>/dev/null
+    echo ""
+    parse_sarif "$sarif"
+}
+
+scan_codeql_js() {
+    local sarif="$PROJECT_ROOT/codeql-javascript-results.sarif"
+    if ! has_codeql; then
+        echo "SKIP: CodeQL CLI not installed."
+        return 2
+    fi
+    echo "Creating database..."
+    gh codeql database create --overwrite --language=javascript --source-root=frontend /tmp/bambuddy-codeql-javascript &>/dev/null
+    echo "Analyzing..."
+    gh codeql database analyze /tmp/bambuddy-codeql-javascript \
+        codeql/javascript-queries:codeql-suites/javascript-security-and-quality.qls \
+        --format=sarifv2.1.0 --output="$sarif" &>/dev/null
+    echo ""
+    parse_sarif "$sarif"
+}
+
+scan_codeql_actions() {
+    local sarif="$PROJECT_ROOT/codeql-actions-results.sarif"
+    if ! has_codeql; then
+        echo "SKIP: CodeQL CLI not installed."
+        return 2
+    fi
+    echo "Creating database..."
+    gh codeql database create --overwrite --language=actions /tmp/bambuddy-codeql-actions &>/dev/null
+    echo "Analyzing..."
+    gh codeql database analyze /tmp/bambuddy-codeql-actions \
+        codeql/actions-queries \
+        --format=sarifv2.1.0 --output="$sarif" &>/dev/null
+    echo ""
+    parse_sarif "$sarif"
+}
+
+scan_trivy_image() {
+    if ! check_command trivy; then
+        echo "SKIP: 'trivy' not found. Install: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh"
+        return 2
+    fi
+    if ! check_command docker; then
+        echo "SKIP: 'docker' not found."
+        return 2
+    fi
+    echo "Building Docker image..."
+    docker build -t bambuddy:security-scan . 2>&1
+    echo ""
+    trivy image --severity CRITICAL,HIGH,MEDIUM bambuddy:security-scan 2>&1
+}
+
+scan_trivy_config() {
+    if ! check_command trivy; then
+        echo "SKIP: 'trivy' not found. Install: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh"
+        return 2
+    fi
+    trivy config --severity CRITICAL,HIGH,MEDIUM . 2>&1
+}
+
+scan_pip_audit() {
+    if ! check_command pip-audit; then
+        echo "SKIP: 'pip-audit' not found. Install: pip install pip-audit"
+        return 2
+    fi
+    pip-audit --desc on 2>&1
+}
+
+scan_npm_audit() {
+    if ! check_command npm; then
+        echo "SKIP: 'npm' not found. Install Node.js"
+        return 2
+    fi
+    (cd frontend && npm audit --audit-level=high) 2>&1
+}
+
+# ── Job launcher (streams output live with prefix, captures to log) ──────
+
+launch_scan() {
+    local name="$1"
+    local func="$2"
+    local prefix
+    prefix=$(printf "${DIM}[%-14s]${NC} " "$name")
+
+    SCAN_ORDER+=("$name")
+
+    (
+        set -o pipefail
+        local start_time
+        start_time=$(date +%s)
+
+        "$func" 2>&1 | tee "$WORK_DIR/${name}.log" | sed "s|^|${prefix}|"
+        local exit_code=${PIPESTATUS[0]}
+
+        echo $(( $(date +%s) - start_time )) > "$WORK_DIR/${name}.duration"
+        exit "$exit_code"
+    ) &
+    PIDS["$name"]=$!
+}
+
+# ── Wait for all scans ───────────────────────────────────────────────────
+
+wait_for_scans() {
+    local total=${#PIDS[@]}
+    local completed=0
+
+    while [ "$completed" -lt "$total" ]; do
+        for name in "${SCAN_ORDER[@]}"; do
+            local pid=${PIDS[$name]:-}
+            [ -z "$pid" ] && continue
+
+            if ! kill -0 "$pid" 2>/dev/null; then
+                wait "$pid" 2>/dev/null
+                local exit_code=$?
+
+                if [ "$exit_code" -eq 2 ]; then
+                    RESULTS["$name"]="SKIP"
+                elif [ "$exit_code" -eq 0 ]; then
+                    RESULTS["$name"]="PASS"
+                else
+                    RESULTS["$name"]="FAIL"
+                fi
+
+                if [ -f "$WORK_DIR/${name}.duration" ]; then
+                    DURATIONS["$name"]=$(cat "$WORK_DIR/${name}.duration")
+                else
+                    DURATIONS["$name"]="?"
+                fi
+
+                local status_color
+                case "${RESULTS[$name]}" in
+                    PASS) status_color="$GREEN" ;;
+                    FAIL) status_color="$RED" ;;
+                    SKIP) status_color="$YELLOW" ;;
+                esac
+                echo -e "${status_color}${BOLD}[${RESULTS[$name]}]${NC} ${name} ${DIM}(${DURATIONS[$name]}s)${NC}"
+
+                unset "PIDS[$name]"
+                completed=$((completed + 1))
+            fi
+        done
+        sleep 0.5
+    done
+}
+
+# ── Summary ──────────────────────────────────────────────────────────────
+
+print_summary() {
+    local pass=0 fail=0 skip=0
+
+    for name in "${SCAN_ORDER[@]}"; do
+        case "${RESULTS[$name]}" in
+            PASS) pass=$((pass + 1)) ;;
+            FAIL) fail=$((fail + 1)) ;;
+            SKIP) skip=$((skip + 1)) ;;
+        esac
+    done
+
+    # ── Results table ────────────────────────────────────────────────────
+
+    echo ""
+    echo -e "${CYAN}${BOLD}══════════════════════════════════════════════════════════════${NC}"
+    echo -e "${CYAN}${BOLD}  Security Scan Results${NC}"
+    echo -e "${CYAN}${BOLD}══════════════════════════════════════════════════════════════${NC}"
+    echo ""
+    printf "  ${BOLD}%-6s  %-24s  %s${NC}\n" "Status" "Scan" "Duration"
+    printf "  %-6s  %-24s  %s\n" "──────" "────────────────────────" "────────"
+
+    for name in "${SCAN_ORDER[@]}"; do
+        local status="${RESULTS[$name]}"
+        local duration="${DURATIONS[$name]:-?}s"
+        local status_color
+        case "$status" in
+            PASS) status_color="$GREEN" ;;
+            FAIL) status_color="$RED" ;;
+            SKIP) status_color="$YELLOW" ;;
+        esac
+        printf "  ${status_color}%-6s${NC}  %-24s  ${DIM}%s${NC}\n" "$status" "$name" "$duration"
+    done
+
+    echo ""
+    echo -e "  ${GREEN}$pass passed${NC}  ${RED}$fail failed${NC}  ${YELLOW}$skip skipped${NC}"
+
+    # ── Full output per scan ─────────────────────────────────────────────
+
+    for name in "${SCAN_ORDER[@]}"; do
+        local log="$WORK_DIR/${name}.log"
+        [ ! -f "$log" ] && continue
+
+        local status="${RESULTS[$name]}"
+        local status_color
+
+        case "$status" in
+            PASS) status_color="$GREEN" ;;
+            FAIL) status_color="$RED" ;;
+            SKIP) status_color="$YELLOW" ;;
+        esac
+
+        echo ""
+        echo -e "${CYAN}──────────────────────────────────────────────────────────────${NC}"
+        echo -e "${BOLD}  $name${NC}  ${status_color}[$status]${NC}"
+        echo -e "${CYAN}──────────────────────────────────────────────────────────────${NC}"
+
+        sed 's/^/  /' "$log"
+    done
+
+    echo ""
+    echo -e "${CYAN}${BOLD}══════════════════════════════════════════════════════════════${NC}"
+    echo ""
+
+    if [ "$fail" -gt 0 ]; then
+        exit 1
+    fi
+}
+
+# ── Main ─────────────────────────────────────────────────────────────────
+
+if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then
+    head -29 "$0" | tail -27
+    exit 0
+fi
+
+echo -e "${BOLD}Bambuddy Security Scanner${NC}"
+echo -e "${DIM}$(date '+%Y-%m-%d %H:%M:%S')  •  $(nproc) CPU cores available${NC}"
+echo ""
+
+SCANS_TO_RUN=()
+
+if [ $# -eq 0 ]; then
+    SCANS_TO_RUN=(bandit pip-audit npm-audit)
+elif [ "$1" = "--full" ]; then
+    SCANS_TO_RUN=(bandit pip-audit npm-audit codeql-actions codeql-python codeql-js trivy-image trivy-config)
+else
+    for scan in "$@"; do
+        case "$scan" in
+            codeql) SCANS_TO_RUN+=(codeql-actions codeql-python codeql-js) ;;
+            trivy)  SCANS_TO_RUN+=(trivy-image trivy-config) ;;
+            bandit|codeql-actions|codeql-python|codeql-js|trivy-image|trivy-config|pip-audit|npm-audit)
+                SCANS_TO_RUN+=("$scan") ;;
+            *)
+                echo -e "${RED}Unknown scan: $scan${NC}"
+                echo "Run with --help for available scans"
+                exit 1
+                ;;
+        esac
+    done
+fi
+
+# Launch all scans in parallel
+for scan in "${SCANS_TO_RUN[@]}"; do
+    case "$scan" in
+        bandit)         launch_scan "bandit"         scan_bandit ;;
+        codeql-actions) launch_scan "codeql-actions" scan_codeql_actions ;;
+        codeql-python)  launch_scan "codeql-python"  scan_codeql_python ;;
+        codeql-js)      launch_scan "codeql-js"      scan_codeql_js ;;
+        trivy-image)    launch_scan "trivy-image"    scan_trivy_image ;;
+        trivy-config)   launch_scan "trivy-config"   scan_trivy_config ;;
+        pip-audit)      launch_scan "pip-audit"      scan_pip_audit ;;
+        npm-audit)      launch_scan "npm-audit"      scan_npm_audit ;;
+    esac
+done
+
+echo -e "${BOLD}Running ${#SCANS_TO_RUN[@]} scan(s) in parallel...${NC}"
+echo ""
+
+wait_for_scans
+print_summary

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio