From 53bd4fadb362e3a7369f0cff6fc6fdf494711658 Mon Sep 17 00:00:00 2001 From: maziggy Date: Fri, 6 Feb 2026 11:37:59 +0100 Subject: [PATCH] Fix safe security findings: hashlib, log injection, broad excepts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .gitignore | 3 + backend/app/api/routes/archives.py | 60 ++- backend/app/api/routes/auth.py | 16 +- backend/app/api/routes/camera.py | 78 ++-- backend/app/api/routes/cloud.py | 4 +- backend/app/api/routes/external_links.py | 12 +- backend/app/api/routes/github_backup.py | 8 +- backend/app/api/routes/kprofiles.py | 8 +- backend/app/api/routes/library.py | 80 ++-- backend/app/api/routes/maintenance.py | 2 +- backend/app/api/routes/notifications.py | 8 +- backend/app/api/routes/pending_uploads.py | 10 +- backend/app/api/routes/print_queue.py | 34 +- backend/app/api/routes/printers.py | 32 +- backend/app/api/routes/projects.py | 12 +- backend/app/api/routes/settings.py | 20 +- backend/app/api/routes/smart_plugs.py | 24 +- backend/app/api/routes/spoolman.py | 24 +- backend/app/api/routes/support.py | 10 +- backend/app/api/routes/updates.py | 14 +- backend/app/api/routes/webhook.py | 6 +- backend/app/api/routes/websocket.py | 4 +- backend/app/core/auth.py | 6 +- backend/app/core/config.py | 4 +- backend/app/core/database.py | 255 +++++------ backend/app/main.py | 296 ++++++------- backend/app/services/archive.py | 29 +- backend/app/services/bambu_cloud.py | 12 +- backend/app/services/bambu_ftp.py | 118 +++--- backend/app/services/bambu_mqtt.py | 266 ++++++------ backend/app/services/camera.py | 44 +- backend/app/services/discovery.py | 86 ++-- backend/app/services/external_camera.py | 94 ++-- backend/app/services/firmware_check.py | 34 +- backend/app/services/firmware_update.py | 10 +- backend/app/services/github_backup.py | 18 +- backend/app/services/homeassistant.py | 27 +- backend/app/services/layer_timelapse.py | 30 +- backend/app/services/mqtt_relay.py | 18 +- backend/app/services/mqtt_smart_plug.py | 34 +- backend/app/services/network_utils.py | 10 +- backend/app/services/notification_service.py | 56 +-- backend/app/services/plate_detection.py | 30 +- backend/app/services/print_scheduler.py | 74 ++-- backend/app/services/printer_manager.py | 10 +- backend/app/services/smart_plug_manager.py | 58 +-- backend/app/services/spoolman.py | 48 +-- backend/app/services/spoolman_tracking.py | 52 +-- backend/app/services/stl_thumbnail.py | 14 +- backend/app/services/tasmota.py | 18 +- backend/app/services/timelapse_processor.py | 6 +- .../services/virtual_printer/certificate.py | 18 +- .../services/virtual_printer/ftp_server.py | 66 +-- .../app/services/virtual_printer/manager.py | 68 +-- .../services/virtual_printer/mqtt_server.py | 102 ++--- .../services/virtual_printer/ssdp_server.py | 70 +-- .../app/services/virtual_printer/tcp_proxy.py | 54 +-- backend/app/utils/threemf_tools.py | 7 +- requirements-dev.txt | 4 + test_security.sh | 401 ++++++++++++++++++ 60 files changed, 1732 insertions(+), 1284 deletions(-) create mode 100755 test_security.sh diff --git a/.gitignore b/.gitignore index dbb6d7a75..d6c64ad59 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,6 @@ data/ # JWT secret file (should be in data dir, but protect project root too) .jwt_secret + +# Security scan output +*.sarif diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index 0faf9883e..474feff36 100644 --- a/backend/app/api/routes/archives.py +++ b/backend/app/api/routes/archives.py @@ -1,4 +1,5 @@ import io +import json import logging import zipfile 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}) matched_ids = [row[0] for row in result.fetchall()] 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 like_pattern = f"%{q}%" query = ( @@ -265,7 +266,7 @@ async def rebuild_search_index( return {"message": f"Search index rebuilt with {count} entries"} 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)}") @@ -941,7 +942,7 @@ async def rescan_all_archives( updated += 1 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"}) await db.commit() @@ -992,7 +993,7 @@ async def backfill_content_hashes( archive.content_hash = ArchiveService.compute_file_hash(file_path) updated += 1 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"}) await db.commit() @@ -1262,7 +1263,7 @@ async def scan_timelapse( # Accept match within 4 hours (more lenient for timezone issues) if best_match and best_diff < timedelta(hours=4): 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 # 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): 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 # 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 time_since_completion < timedelta(hours=1): 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 # 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() return TimelapseInfoResponse(**info) 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)}") @@ -1541,7 +1542,7 @@ async def get_timelapse_thumbnails( timestamps=[ts for ts, _ in thumbnails], ) 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)}") @@ -1647,7 +1648,7 @@ async def process_timelapse( except HTTPException: raise 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)}") finally: # Cleanup temp audio file @@ -1838,8 +1839,6 @@ async def get_archive_capabilities( _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ), ): """Check what viewing capabilities are available for this 3MF file.""" - import json - import defusedxml.ElementTree as ET service = ArchiveService(db) @@ -1883,7 +1882,7 @@ async def get_archive_capabilities( if " {link.url}") + logger.info("Created external link: %s -> %s", link.name, link.url) return link @@ -108,7 +108,7 @@ async def update_external_link( await db.commit() await db.refresh(link) - logger.info(f"Updated external link: {link.name}") + logger.info("Updated external link: %s", link.name) return link @@ -130,7 +130,7 @@ async def delete_external_link( await db.delete(link) await db.commit() - logger.info(f"Deleted external link: {name}") + logger.info("Deleted external link: %s", name) 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)) 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 @@ -205,7 +205,7 @@ async def upload_icon( await db.commit() 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 @@ -230,7 +230,7 @@ async def delete_icon( link.custom_icon = None await db.commit() 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 diff --git a/backend/app/api/routes/github_backup.py b/backend/app/api/routes/github_backup.py index 55cfd1cd0..c2e0ccb6a 100644 --- a/backend/app/api/routes/github_backup.py +++ b/backend/app/api/routes/github_backup.py @@ -97,7 +97,7 @@ async def save_config( else: 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: # Create new config = GitHubBackupConfig( @@ -116,7 +116,7 @@ async def save_config( config.next_scheduled_run = github_backup_service._calculate_next_run(config.schedule_type) 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.refresh(config) @@ -155,7 +155,7 @@ async def update_config( await db.commit() 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) @@ -337,6 +337,6 @@ async def clear_logs( await db.commit() 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"} diff --git a/backend/app/api/routes/kprofiles.py b/backend/app/api/routes/kprofiles.py index 0762f1dec..4b009bb60 100644 --- a/backend/app/api/routes/kprofiles.py +++ b/backend/app/api/routes/kprofiles.py @@ -118,7 +118,7 @@ async def set_kprofile( if is_edit and is_h2d: # 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( filament_id=profile.filament_id, name=profile.name, @@ -132,7 +132,7 @@ async def set_kprofile( ) elif is_edit: # 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( cali_idx=profile.slot_id, filament_id=profile.filament_id, @@ -197,9 +197,9 @@ async def set_kprofiles_batch( if not profiles: 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: - 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 result = await db.execute(select(Printer).where(Printer.id == printer_id)) diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index 785244627..78535e5ff 100644 --- a/backend/app/api/routes/library.py +++ b/backend/app/api/routes/library.py @@ -1,12 +1,14 @@ """API routes for File Manager (Library) functionality.""" import base64 +import binascii import hashlib import logging import os import re import shutil import uuid +import zipfile from pathlib import Path 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 if thumbnail_data is None or best_size > 0: thumbnail_data = decoded - except Exception: + except (binascii.Error, ValueError): pass in_thumbnail = False thumbnail_lines = [] @@ -173,8 +175,8 @@ def extract_gcode_thumbnail(file_path: Path) -> bytes | None: thumbnail_lines.append(data_line) 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 @@ -219,11 +221,11 @@ def create_image_thumbnail(file_path: Path, thumbnails_dir: Path, max_size: int thumb_path = thumbnails_dir / thumb_filename shutil.copy2(file_path, thumb_path) return str(thumb_path) - except Exception: + except OSError: pass return None except Exception as e: - logger.warning(f"Failed to create image thumbnail: {e}") + logger.warning("Failed to create image thumbnail: %s", e) return None @@ -595,8 +597,8 @@ async def delete_folder( os.remove(file_path) if thumb_path and os.path.exists(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 children_result = await db.execute(select(LibraryFolder.id).where(LibraryFolder.parent_id == fid)) @@ -772,8 +774,8 @@ async def upload_file( return obj 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": # Extract embedded thumbnail from gcode @@ -785,8 +787,8 @@ async def upload_file( with open(thumb_path, "wb") as f: f.write(thumbnail_data) 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: # For image files, create a thumbnail from the image itself @@ -825,7 +827,7 @@ async def upload_file( except HTTPException: raise 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)}") @@ -866,7 +868,7 @@ async def extract_zip_file( content = await file.read() tmp.write(content) 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)}") extracted_files: list[ZipExtractResult] = [] @@ -892,7 +894,7 @@ async def extract_zip_file( existing_folder = existing.scalar_one_or_none() if existing_folder: 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: # Create folder 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 zip_folder_id = new_folder.id 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: with zipfile.ZipFile(tmp_path, "r") as zf: @@ -1012,8 +1014,8 @@ async def extract_zip_file( return obj 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": try: @@ -1024,8 +1026,8 @@ async def extract_zip_file( with open(thumb_path, "wb") as f: f.write(thumbnail_data) 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: thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir) @@ -1064,7 +1066,7 @@ async def extract_zip_file( await db.commit() 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))) # Rollback the failed file but continue with others await db.rollback() @@ -1079,13 +1081,13 @@ async def extract_zip_file( except zipfile.BadZipFile: raise HTTPException(status_code=400, detail="Invalid or corrupted ZIP file") 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)}") finally: # Clean up temp file try: os.unlink(tmp_path) - except Exception: + except OSError: pass @@ -1184,7 +1186,7 @@ async def batch_generate_stl_thumbnails( ) failed += 1 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( BatchThumbnailResult( file_id=stl_file.id, @@ -1291,7 +1293,7 @@ async def add_files_to_queue( ) 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))) await db.commit() @@ -1424,7 +1426,7 @@ async def get_library_file_plates( plate_object_ids.setdefault(plater_id, []) if obj_id not in plate_object_ids[plater_id]: plate_object_ids[plater_id].append(obj_id) - except Exception: + except (KeyError, ValueError, ET.ParseError, UnicodeDecodeError): pass # Parse slice_info.config for plate metadata @@ -1517,7 +1519,7 @@ async def get_library_file_plates( names.append(obj_name) if names: plate_json_objects[plate_index] = names - except Exception: + except (json.JSONDecodeError, KeyError, ValueError, UnicodeDecodeError): continue # 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 { "file_id": file_id, @@ -1592,7 +1594,7 @@ async def get_library_file_plate_thumbnail( if thumb_path in zf.namelist(): data = zf.read(thumb_path) return Response(content=data, media_type="image/png") - except Exception: + except (zipfile.BadZipFile, KeyError, OSError): pass 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 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 { "file_id": file_id, @@ -1821,7 +1823,7 @@ async def print_library_file( ) # 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( printer.ip_address, printer.access_code, @@ -1829,7 +1831,7 @@ async def print_library_file( socket_timeout=ftp_timeout, printer_model=printer.model, ) - logger.debug(f"Delete result: {delete_result}") + logger.debug("Delete result: %s", delete_result) # Upload file to printer if ftp_retry_enabled: @@ -1882,7 +1884,7 @@ async def print_library_file( plate_str = name[15:-6] plate_id = int(plate_str) break - except Exception: + except (ValueError, zipfile.BadZipFile, OSError): pass logger.info( @@ -2103,8 +2105,8 @@ async def delete_file( abs_file_path.unlink() if abs_thumb_path and abs_thumb_path.exists(): 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) @@ -2284,8 +2286,8 @@ async def bulk_delete( abs_file_path.unlink() if abs_thumb_path and abs_thumb_path.exists(): 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) deleted_files += 1 @@ -2348,7 +2350,7 @@ async def get_library_stats( disk_free_bytes = disk_stat.free disk_total_bytes = disk_stat.total disk_used_bytes = disk_stat.used - except Exception: + except OSError: disk_free_bytes = 0 disk_total_bytes = 0 disk_used_bytes = 0 diff --git a/backend/app/api/routes/maintenance.py b/backend/app/api/routes/maintenance.py index d2d0a3167..ac61ab1d1 100644 --- a/backend/app/api/routes/maintenance.py +++ b/backend/app/api/routes/maintenance.py @@ -654,7 +654,7 @@ async def set_printer_hours( f"{len(items_needing_attention)} items need attention" ) except Exception as e: - logger.warning(f"Failed to send maintenance notification: {e}") + logger.warning("Failed to send maintenance notification: %s", e) return { "printer_id": printer_id, diff --git a/backend/app/api/routes/notifications.py b/backend/app/api/routes/notifications.py index bc5ee5ab2..4523a570a 100644 --- a/backend/app/api/routes/notifications.py +++ b/backend/app/api/routes/notifications.py @@ -146,7 +146,7 @@ async def create_notification_provider( await db.commit() 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) @@ -345,7 +345,7 @@ async def clear_notification_logs( await db.commit() 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"} @@ -399,7 +399,7 @@ async def update_notification_provider( await db.commit() 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) @@ -421,7 +421,7 @@ async def delete_notification_provider( await db.delete(provider) await db.commit() - logger.info(f"Deleted notification provider: {name}") + logger.info("Deleted notification provider: %s", name) return {"message": f"Notification provider '{name}' deleted"} diff --git a/backend/app/api/routes/pending_uploads.py b/backend/app/api/routes/pending_uploads.py index 29d48b44e..57f40a5a4 100644 --- a/backend/app/api/routes/pending_uploads.py +++ b/backend/app/api/routes/pending_uploads.py @@ -113,11 +113,11 @@ async def archive_all_pending( # Clean up temp file try: file_path.unlink() - except Exception: + except OSError: pass else: failed += 1 - except Exception: + except Exception: # Mixed async DB + archive operations failed += 1 await db.commit() @@ -144,7 +144,7 @@ async def discard_all_pending( try: file_path = Path(pending.file_path) file_path.unlink(missing_ok=True) - except Exception: + except OSError: pass pending.status = "discarded" @@ -230,7 +230,7 @@ async def archive_pending_upload( # Clean up temp file try: file_path.unlink() - except Exception: + except OSError: pass return { @@ -257,7 +257,7 @@ async def discard_pending_upload( file_path = Path(pending.file_path) try: file_path.unlink(missing_ok=True) - except Exception: + except OSError: pass # Update status diff --git a/backend/app/api/routes/print_queue.py b/backend/app/api/routes/print_queue.py index fd6584507..abd5d7f87 100644 --- a/backend/app/api/routes/print_queue.py +++ b/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: 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) @@ -144,8 +144,8 @@ def _extract_print_time_from_3mf(file_path: Path, plate_id: int | None = None) - return int(meta.get("value", "0")) except ValueError: 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 @@ -335,7 +335,7 @@ async def add_to_queue( filament_types = _extract_filament_types_from_3mf(file_path, data.plate_id) if 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) 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}" 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 try: @@ -481,7 +481,7 @@ async def bulk_update_queue_items( 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( updated_count=updated_count, skipped_count=skipped_count, @@ -581,7 +581,7 @@ async def update_queue_item( await db.commit() 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) @@ -615,7 +615,7 @@ async def delete_queue_item( await db.delete(item) await db.commit() - logger.info(f"Deleted queue item {item_id}") + logger.info("Deleted queue item %s", item_id) return {"message": "Queue item deleted"} @@ -633,7 +633,7 @@ async def reorder_queue( item.position = reorder_item.position 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"} @@ -668,7 +668,7 @@ async def cancel_queue_item( item.completed_at = datetime.now() await db.commit() - logger.info(f"Cancelled queue item {item_id}") + logger.info("Cancelled queue item %s", item_id) return {"message": "Queue item cancelled"} @@ -702,9 +702,9 @@ async def stop_queue_item( try: stop_sent = printer_manager.stop_print(printer_id) 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: - 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 item.status = "cancelled" @@ -720,13 +720,13 @@ async def stop_queue_item( if plug and plug.enabled: 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 if plug_ip: 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) # Re-fetch plug since we're in a new async context 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)) plug = result.scalar_one_or_none() 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) asyncio.create_task(cooldown_and_poweroff()) @@ -771,5 +771,5 @@ async def start_queue_item( await db.commit() 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) diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index 2dbcc9e38..8bf24ec19 100644 --- a/backend/app/api/routes/printers.py +++ b/backend/app/api/routes/printers.py @@ -383,13 +383,13 @@ async def get_printer_status( ams_mapping = raw_data.get("ams_mapping", []) # Get per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left 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 # Per OpenBambuAPI docs: 254 = external spool, 255 = no filament, otherwise global tray ID # No conversion needed - just use the raw value directly 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 # 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) if match: 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 view_key = view or "default" @@ -643,10 +643,10 @@ async def get_printer_cover( except Exception as e: last_error = e 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 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: 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}") 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: temp_path.unlink() @@ -674,8 +674,8 @@ async def get_printer_cover( zf = zipfile.ZipFile(temp_path, "r") except zipfile.BadZipFile: 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.") try: @@ -1076,7 +1076,7 @@ async def get_printer_file_plates( ) 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 { "printer_id": printer_id, @@ -1114,7 +1114,7 @@ async def get_printer_file_plate_thumbnail( if thumb_path in zf.namelist(): image_data = zf.read(thumb_path) return Response(content=image_data, media_type="image/png") - except Exception: + except (zipfile.BadZipFile, KeyError, OSError): pass 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] zf.writestr(filename, data) 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 zip_buffer.seek(0) @@ -1597,7 +1597,7 @@ async def configure_ams_slot( import logging 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( 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 logger.info("[configure_ams_slot] Requesting status update from printer") 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 { "success": True, @@ -1758,7 +1758,7 @@ async def debug_simulate_print_complete( "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 await on_print_complete(printer_id, data) @@ -1932,9 +1932,9 @@ async def get_printable_objects( if objects: client.state.printable_objects = objects 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: - logger.debug(f"Failed to reload objects from printer: {e}") + logger.debug("Failed to reload objects from printer: %s", e) finally: if temp_path.exists(): temp_path.unlink() diff --git a/backend/app/api/routes/projects.py b/backend/app/api/routes/projects.py index 82a5289f9..4e9eb588f 100644 --- a/backend/app/api/routes/projects.py +++ b/backend/app/api/routes/projects.py @@ -829,7 +829,7 @@ async def upload_attachment( _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE), ): """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 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: content = await file.read() 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: - 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") # Update project attachments JSON @@ -878,7 +878,7 @@ async def upload_attachment( project.attachments = attachments 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.commit() @@ -889,7 +889,7 @@ async def upload_attachment( result = await db.execute(select(Project).where(Project.id == project_id)) 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 { "status": "success", @@ -969,7 +969,7 @@ async def delete_attachment( try: os.remove(file_path) 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.refresh(project) diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index b90907812..56cc5a83e 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -294,9 +294,9 @@ async def create_backup( except shutil.Error as e: # Some files may have restricted permissions (e.g., SSL keys) # 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: - logger.warning(f"Permission denied copying {name}: {e}") + logger.warning("Permission denied copying %s: %s", name, e) # 4. Create ZIP zip_buffer = io.BytesIO() @@ -315,7 +315,7 @@ async def create_backup( headers={"Content-Disposition": f"attachment; filename={filename}"}, ) except Exception as e: - logger.error(f"Backup failed: {e}", exc_info=True) + logger.error("Backup failed: %s", e, exc_info=True) return JSONResponse( status_code=500, 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 await asyncio.sleep(1) 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 logger.info("Closing database connections...") @@ -400,7 +400,7 @@ async def restore_backup( for name, dest_dir in dirs_to_restore: src_dir = temp_path / name if src_dir.exists(): - logger.info(f"Restoring {name} directory...") + logger.info("Restoring %s directory...", name) try: # Clear destination contents (not the dir itself - may be Docker mount) if dest_dir.exists(): @@ -411,7 +411,7 @@ async def restore_backup( else: item.unlink() except OSError as e: - logger.warning(f"Could not delete {item}: {e}") + logger.warning("Could not delete %s: %s", item, e) else: dest_dir.mkdir(parents=True, exist_ok=True) # Copy contents from backup @@ -422,7 +422,7 @@ async def restore_backup( else: shutil.copy2(item, dest_item) 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) # 7. Note: Virtual printer and database will be reinitialized on restart @@ -438,7 +438,7 @@ async def restore_backup( } except Exception as e: - logger.error(f"Restore failed: {e}", exc_info=True) + logger.error("Restore failed: %s", e, exc_info=True) return JSONResponse( status_code=500, 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, ) except ValueError as e: - logger.warning(f"Virtual printer configuration validation error: {e}") + logger.warning("Virtual printer configuration validation error: %s", e) return JSONResponse( status_code=400, content={"detail": "Invalid virtual printer configuration. Check the provided values."}, ) 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( status_code=500, content={"detail": "Failed to configure virtual printer. Check server logs for details."}, diff --git a/backend/app/api/routes/smart_plugs.py b/backend/app/api/routes/smart_plugs.py index 9ddb352ed..c15352db9 100644 --- a/backend/app/api/routes/smart_plugs.py +++ b/backend/app/api/routes/smart_plugs.py @@ -144,11 +144,11 @@ async def create_smart_plug( state_on_value=plug.mqtt_state_on_value, ) 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": - 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: - 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 @@ -230,11 +230,11 @@ def get_local_network_range() -> tuple[str, str]: from_ip = f"{base}.1" 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 - 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 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, ) - logger.info(f"Updated smart plug '{plug.name}'") + logger.info("Updated smart plug '%s'", plug.name) return plug @@ -535,7 +535,7 @@ async def delete_smart_plug( await db.delete(plug) await db.commit() - logger.info(f"Deleted smart plug '{plug_name}'") + logger.info("Deleted smart plug '%s'", plug_name) 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 if plug_state == "ON" and plug.auto_on: 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: 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: try: service = await _get_service_for_plug(plug, db) await service.turn_on(plug) # Scripts are triggered by calling turn_on 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) @@ -780,7 +780,7 @@ async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: A else: 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) await notification_service.send_notification( diff --git a/backend/app/api/routes/spoolman.py b/backend/app/api/routes/spoolman.py index a48ab1cc3..c718a95b4 100644 --- a/backend/app/api/routes/spoolman.py +++ b/backend/app/api/routes/spoolman.py @@ -130,7 +130,7 @@ async def connect_spoolman( return {"success": True, "message": f"Connected to Spoolman at {url}"} 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)) @@ -209,7 +209,7 @@ async def sync_printer_ams( # Single AMS unit format - wrap in list ams_units = [{"id": 0, "tray": ams_data.get("tray", [])}] 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: 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) if sync_result: 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: # 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}") @@ -273,9 +275,9 @@ async def sync_printer_ams( try: cleared = await client.clear_location_for_removed_spools(printer.name, current_tray_uuids) 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: - logger.error(f"Error clearing locations for removed spools: {e}") + logger.error("Error clearing locations for removed spools: %s", e) return SyncResult( success=len(errors) == 0, @@ -344,15 +346,15 @@ async def sync_all_printers( # Single AMS unit format - wrap in list ams_units = [{"id": 0, "tray": ams_data.get("tray", [])}] 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: - 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 for ams_unit in ams_units: 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 ams_id = int(ams_unit.get("id", 0)) @@ -404,9 +406,9 @@ async def sync_all_printers( try: cleared = await client.clear_location_for_removed_spools(printer_name, current_tray_uuids) 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: - logger.error(f"Error clearing locations for {printer_name}: {e}") + logger.error("Error clearing locations for %s: %s", printer_name, e) return SyncResult( success=len(all_errors) == 0, @@ -609,7 +611,7 @@ async def link_spool( ) 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"} else: raise HTTPException(status_code=500, detail="Failed to update spool") diff --git a/backend/app/api/routes/support.py b/backend/app/api/routes/support.py index 3c882e7de..ce12542dc 100644 --- a/backend/app/api/routes/support.py +++ b/backend/app/api/routes/support.py @@ -106,7 +106,7 @@ def _apply_log_level(debug: bool): logging.getLogger("httpcore").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) @@ -269,7 +269,7 @@ def _read_log_entries( entries.append(current_entry) except Exception as e: - logger.error(f"Error reading log file: {e}") + logger.error("Error reading log file: %s", e) return [], 0 # Entries are already in newest-first order @@ -308,7 +308,7 @@ async def clear_logs( logger.info("Log file cleared by user") return {"message": "Logs cleared successfully"} 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.") return {"message": "Log file does not exist"} @@ -493,7 +493,7 @@ async def generate_support_bundle( zip_buffer.seek(0) filename = f"bambuddy-support-{timestamp}.zip" - logger.info(f"Generated support bundle: {filename}") + logger.info("Generated support bundle: %s", filename) return StreamingResponse( 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) logger.info("Debug logging restored from previous session") except Exception as e: - logger.warning(f"Could not restore debug logging state: {e}") + logger.warning("Could not restore debug logging state: %s", e) diff --git a/backend/app/api/routes/updates.py b/backend/app/api/routes/updates.py index d26be6c13..b1f73baac 100644 --- a/backend/app/api/routes/updates.py +++ b/backend/app/api/routes/updates.py @@ -236,7 +236,7 @@ async def check_for_updates( } 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 = { "status": "error", "progress": 0, @@ -269,7 +269,7 @@ async def _perform_update(): } 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 = ["-c", f"safe.directory={base_dir}"] @@ -318,7 +318,7 @@ async def _perform_update(): if process.returncode != 0: 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 = { "status": "error", "progress": 0, @@ -349,7 +349,7 @@ async def _perform_update(): if process.returncode != 0: 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 = { "status": "error", "progress": 0, @@ -381,7 +381,7 @@ async def _perform_update(): stdout, stderr = await process.communicate() 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) npm_path = _find_executable("npm") @@ -417,7 +417,7 @@ async def _perform_update(): stdout, stderr = await process.communicate() 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: 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") except Exception as e: - logger.error(f"Update failed: {e}") + logger.error("Update failed: %s", e) _update_status = { "status": "error", "progress": 0, diff --git a/backend/app/api/routes/webhook.py b/backend/app/api/routes/webhook.py index 504a1dd32..41b9fff13 100644 --- a/backend/app/api/routes/webhook.py +++ b/backend/app/api/routes/webhook.py @@ -179,7 +179,7 @@ async def webhook_start_print( plate_id=queue_item.plate_id or 1, ) 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)) return {"message": "Print started", "queue_item_id": queue_item.id} @@ -207,7 +207,7 @@ async def webhook_stop_print( try: await printer_manager.stop_print(printer_id) 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)) return {"message": "Print stopped"} @@ -235,7 +235,7 @@ async def webhook_cancel_print( try: await printer_manager.cancel_print(printer_id) 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)) return {"message": "Print cancelled"} diff --git a/backend/app/api/routes/websocket.py b/backend/app/api/routes/websocket.py index 24aa192cb..d6d6f3a6d 100644 --- a/backend/app/api/routes/websocket.py +++ b/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)), } ) - 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 while True: @@ -55,5 +55,5 @@ async def websocket_endpoint(websocket: WebSocket): logger.info("WebSocket client disconnected normally") await ws_manager.disconnect(websocket) 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) diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index ba08e5f12..e5a4f1349 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -63,7 +63,7 @@ def _get_jwt_secret() -> str: if secret and len(secret) >= 32: logger.info("Using JWT secret from %s", secret_file) return secret - except Exception as e: + except OSError as e: logger.warning("Failed to read JWT secret file: %s", e) # 3. Generate new random secret @@ -79,7 +79,7 @@ def _get_jwt_secret() -> str: # Restrict permissions (owner read/write only) secret_file.chmod(0o600) logger.info("Generated new JWT secret and saved to %s", secret_file) - except Exception as e: + except OSError as e: logger.warning( "Could not save JWT secret to file (%s). " "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() return api_key except Exception as e: - logger.warning(f"API key validation error: {e}") + logger.warning("API key validation error: %s", e) return None diff --git a/backend/app/core/config.py b/backend/app/core/config.py index b3c13d941..1fe70f11d 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -35,9 +35,9 @@ def _migrate_database() -> Path: if old_db.exists() and not new_db.exists(): try: 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: - 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 # If old database exists (and new one now exists too), it was migrated diff --git a/backend/app/core/database.py b/backend/app/core/database.py index ce7d44dd7..bf478cdb6 100644 --- a/backend/app/core/database.py +++ b/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.orm import DeclarativeBase @@ -98,113 +99,113 @@ async def run_migrations(conn): # Migration: Add is_favorite column to print_archives try: await conn.execute(text("ALTER TABLE print_archives ADD COLUMN is_favorite BOOLEAN DEFAULT 0")) - except Exception: + except OperationalError: # Column already exists pass # Migration: Add content_hash column to print_archives for duplicate detection try: await conn.execute(text("ALTER TABLE print_archives ADD COLUMN content_hash VARCHAR(64)")) - except Exception: + except OperationalError: # Column already exists pass # Migration: Add auto_off_executed column to smart_plugs try: await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN auto_off_executed BOOLEAN DEFAULT 0")) - except Exception: + except OperationalError: # Column already exists pass # Migration: Add on_print_stopped column to notification_providers try: await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_print_stopped BOOLEAN DEFAULT 1")) - except Exception: + except OperationalError: # Column already exists pass # Migration: Add source_3mf_path column to print_archives try: await conn.execute(text("ALTER TABLE print_archives ADD COLUMN source_3mf_path VARCHAR(500)")) - except Exception: + except OperationalError: # Column already exists pass # Migration: Add f3d_path column to print_archives for Fusion 360 design files try: await conn.execute(text("ALTER TABLE print_archives ADD COLUMN f3d_path VARCHAR(500)")) - except Exception: + except OperationalError: # Column already exists pass # Migration: Add on_maintenance_due column to notification_providers try: await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_maintenance_due BOOLEAN DEFAULT 0")) - except Exception: + except OperationalError: # Column already exists pass # Migration: Add location column to printers for grouping try: await conn.execute(text("ALTER TABLE printers ADD COLUMN location VARCHAR(100)")) - except Exception: + except OperationalError: # Column already exists pass # Migration: Add interval_type column to maintenance_types try: await conn.execute(text("ALTER TABLE maintenance_types ADD COLUMN interval_type VARCHAR(20) DEFAULT 'hours'")) - except Exception: + except OperationalError: # Column already exists pass # Migration: Add custom_interval_type column to printer_maintenance try: await conn.execute(text("ALTER TABLE printer_maintenance ADD COLUMN custom_interval_type VARCHAR(20)")) - except Exception: + except OperationalError: # Column already exists pass # Migration: Add power alert columns to smart_plugs try: await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN power_alert_enabled BOOLEAN DEFAULT 0")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN power_alert_high REAL")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN power_alert_low REAL")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN power_alert_last_triggered DATETIME")) - except Exception: + except OperationalError: pass # Migration: Add schedule columns to smart_plugs try: await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN schedule_enabled BOOLEAN DEFAULT 0")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN schedule_on_time VARCHAR(5)")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN schedule_off_time VARCHAR(5)")) - except Exception: + except OperationalError: pass # Migration: Add daily digest columns to notification_providers try: await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN daily_digest_enabled BOOLEAN DEFAULT 0")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN daily_digest_time VARCHAR(5)")) - except Exception: + except OperationalError: pass # Migration: Add project_id column to print_archives @@ -212,7 +213,7 @@ async def run_migrations(conn): await conn.execute( text("ALTER TABLE print_archives ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL") ) - except Exception: + except OperationalError: pass # Migration: Add project_id column to print_queue @@ -220,7 +221,7 @@ async def run_migrations(conn): await conn.execute( text("ALTER TABLE print_queue ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL") ) - except Exception: + except OperationalError: pass # Migration: Create FTS5 virtual table for archive full-text search @@ -239,7 +240,7 @@ async def run_migrations(conn): ) """) ) - except Exception: + except OperationalError: pass # Migration: Create triggers to keep FTS index in sync @@ -252,7 +253,7 @@ async def run_migrations(conn): END """) ) - except Exception: + except OperationalError: pass try: @@ -264,7 +265,7 @@ async def run_migrations(conn): END """) ) - except Exception: + except OperationalError: pass try: @@ -278,29 +279,29 @@ async def run_migrations(conn): END """) ) - except Exception: + except OperationalError: pass # Migration: Add auto_off_pending columns to smart_plugs (for restart recovery) try: await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN auto_off_pending BOOLEAN DEFAULT 0")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN auto_off_pending_since DATETIME")) - except Exception: + except OperationalError: pass # Migration: Add AMS alarm notification columns to notification_providers try: await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_ams_humidity_high BOOLEAN DEFAULT 0")) - except Exception: + except OperationalError: pass try: await conn.execute( text("ALTER TABLE notification_providers ADD COLUMN on_ams_temperature_high BOOLEAN DEFAULT 0") ) - except Exception: + except OperationalError: pass # Migration: Add AMS-HT alarm notification columns to notification_providers @@ -308,67 +309,67 @@ async def run_migrations(conn): await conn.execute( text("ALTER TABLE notification_providers ADD COLUMN on_ams_ht_humidity_high BOOLEAN DEFAULT 0") ) - except Exception: + except OperationalError: pass try: await conn.execute( text("ALTER TABLE notification_providers ADD COLUMN on_ams_ht_temperature_high BOOLEAN DEFAULT 0") ) - except Exception: + except OperationalError: pass # Migration: Add plate not empty notification column to notification_providers try: await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_plate_not_empty BOOLEAN DEFAULT 1")) - except Exception: + except OperationalError: pass # Migration: Add notes column to projects (Phase 2) try: await conn.execute(text("ALTER TABLE projects ADD COLUMN notes TEXT")) - except Exception: + except OperationalError: pass # Migration: Add attachments column to projects (Phase 3) try: await conn.execute(text("ALTER TABLE projects ADD COLUMN attachments JSON")) - except Exception: + except OperationalError: pass # Migration: Add tags column to projects (Phase 4) try: await conn.execute(text("ALTER TABLE projects ADD COLUMN tags TEXT")) - except Exception: + except OperationalError: pass # Migration: Add due_date column to projects (Phase 5) try: await conn.execute(text("ALTER TABLE projects ADD COLUMN due_date DATETIME")) - except Exception: + except OperationalError: pass # Migration: Add priority column to projects (Phase 5) try: await conn.execute(text("ALTER TABLE projects ADD COLUMN priority VARCHAR(20) DEFAULT 'normal'")) - except Exception: + except OperationalError: pass # Migration: Add budget column to projects (Phase 6) try: await conn.execute(text("ALTER TABLE projects ADD COLUMN budget REAL")) - except Exception: + except OperationalError: pass # Migration: Add is_template column to projects (Phase 8) try: await conn.execute(text("ALTER TABLE projects ADD COLUMN is_template BOOLEAN DEFAULT 0")) - except Exception: + except OperationalError: pass # Migration: Add template_source_id column to projects (Phase 8) try: await conn.execute(text("ALTER TABLE projects ADD COLUMN template_source_id INTEGER")) - except Exception: + except OperationalError: pass # Migration: Add parent_id column to projects (Phase 10) @@ -376,77 +377,77 @@ async def run_migrations(conn): await conn.execute( text("ALTER TABLE projects ADD COLUMN parent_id INTEGER REFERENCES projects(id) ON DELETE SET NULL") ) - except Exception: + except OperationalError: pass # Migration: Rename quantity_printed to quantity_acquired in project_bom_items try: await conn.execute(text("ALTER TABLE project_bom_items RENAME COLUMN quantity_printed TO quantity_acquired")) - except Exception: + except OperationalError: pass # Migration: Add unit_price column to project_bom_items try: await conn.execute(text("ALTER TABLE project_bom_items ADD COLUMN unit_price REAL")) - except Exception: + except OperationalError: pass # Migration: Add sourcing_url column to project_bom_items try: await conn.execute(text("ALTER TABLE project_bom_items ADD COLUMN sourcing_url VARCHAR(512)")) - except Exception: + except OperationalError: pass # Migration: Rename notes to remarks in project_bom_items try: await conn.execute(text("ALTER TABLE project_bom_items RENAME COLUMN notes TO remarks")) - except Exception: + except OperationalError: pass # Migration: Add show_in_switchbar column to smart_plugs try: await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN show_in_switchbar BOOLEAN DEFAULT 0")) - except Exception: + except OperationalError: pass # Migration: Add runtime tracking columns to printers try: await conn.execute(text("ALTER TABLE printers ADD COLUMN runtime_seconds INTEGER DEFAULT 0")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE printers ADD COLUMN last_runtime_update DATETIME")) - except Exception: + except OperationalError: pass # Migration: Add quantity column to print_archives for tracking item count try: await conn.execute(text("ALTER TABLE print_archives ADD COLUMN quantity INTEGER DEFAULT 1")) - except Exception: + except OperationalError: pass # Migration: Add manual_start column to print_queue for staged prints try: await conn.execute(text("ALTER TABLE print_queue ADD COLUMN manual_start BOOLEAN DEFAULT 0")) - except Exception: + except OperationalError: pass # Migration: Add wiki_url column to maintenance_types for documentation links try: await conn.execute(text("ALTER TABLE maintenance_types ADD COLUMN wiki_url VARCHAR(500)")) - except Exception: + except OperationalError: pass # Migration: Add ams_mapping column to print_queue for storing filament slot assignments try: await conn.execute(text("ALTER TABLE print_queue ADD COLUMN ams_mapping TEXT")) - except Exception: + except OperationalError: pass # Migration: Add target_parts_count column to projects for tracking total parts needed try: await conn.execute(text("ALTER TABLE projects ADD COLUMN target_parts_count INTEGER")) - except Exception: + except OperationalError: pass # 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("ALTER TABLE print_queue_new RENAME TO print_queue")) - except Exception: + except OperationalError: pass # Migration: Add plug_type column to smart_plugs for HA integration try: await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN plug_type VARCHAR(20) DEFAULT 'tasmota'")) - except Exception: + except OperationalError: pass # Migration: Add ha_entity_id column to smart_plugs for HA integration try: await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN ha_entity_id VARCHAR(100)")) - except Exception: + except OperationalError: pass # 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( text("ALTER TABLE library_folders ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL") ) - except Exception: + except OperationalError: pass # 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" ) ) - except Exception: + except OperationalError: pass # 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("ALTER TABLE smart_plugs_new RENAME TO smart_plugs")) - except Exception: + except OperationalError: pass # Migration: Add plate_id column to print_queue for multi-plate 3MF support try: await conn.execute(text("ALTER TABLE print_queue ADD COLUMN plate_id INTEGER")) - except Exception: + except OperationalError: pass # Migration: Add print options columns to print_queue try: await conn.execute(text("ALTER TABLE print_queue ADD COLUMN bed_levelling BOOLEAN DEFAULT 1")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE print_queue ADD COLUMN flow_cali BOOLEAN DEFAULT 0")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE print_queue ADD COLUMN vibration_cali BOOLEAN DEFAULT 1")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE print_queue ADD COLUMN layer_inspect BOOLEAN DEFAULT 0")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE print_queue ADD COLUMN timelapse BOOLEAN DEFAULT 0")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE print_queue ADD COLUMN use_ams BOOLEAN DEFAULT 1")) - except Exception: + except OperationalError: pass # 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" ) ) - except Exception: + except OperationalError: pass # 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("ALTER TABLE print_queue_new2 RENAME TO print_queue")) - except Exception: + except OperationalError: pass # Migration: Add HA energy sensor entity columns to smart_plugs try: await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN ha_power_entity VARCHAR(100)")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN ha_energy_today_entity VARCHAR(100)")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN ha_energy_total_entity VARCHAR(100)")) - except Exception: + except OperationalError: pass # 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)")) - except Exception: + except OperationalError: pass # Migration: Add external camera columns to printers try: await conn.execute(text("ALTER TABLE printers ADD COLUMN external_camera_url VARCHAR(500)")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE printers ADD COLUMN external_camera_type VARCHAR(20)")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE printers ADD COLUMN external_camera_enabled BOOLEAN DEFAULT 0")) - except Exception: + except OperationalError: pass # Migration: Add external_url column to print_archives for user-defined links (Printables, etc.) try: await conn.execute(text("ALTER TABLE print_archives ADD COLUMN external_url VARCHAR(500)")) - except Exception: + except OperationalError: pass # Migration: Add sliced_for_model column to print_archives for model-based queue assignment try: await conn.execute(text("ALTER TABLE print_archives ADD COLUMN sliced_for_model VARCHAR(50)")) - except Exception: + except OperationalError: pass # Migration: Add is_external column to library_files for external cloud files try: await conn.execute(text("ALTER TABLE library_files ADD COLUMN is_external BOOLEAN DEFAULT 0")) - except Exception: + except OperationalError: pass # Migration: Add project_id column to library_files @@ -747,51 +748,51 @@ async def run_migrations(conn): await conn.execute( text("ALTER TABLE library_files ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL") ) - except Exception: + except OperationalError: pass # Migration: Add is_external column to library_folders for external cloud folders try: await conn.execute(text("ALTER TABLE library_folders ADD COLUMN is_external BOOLEAN DEFAULT 0")) - except Exception: + except OperationalError: pass # Migration: Add external folder settings columns to library_folders try: await conn.execute(text("ALTER TABLE library_folders ADD COLUMN external_readonly BOOLEAN DEFAULT 0")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE library_folders ADD COLUMN external_show_hidden BOOLEAN DEFAULT 0")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE library_folders ADD COLUMN external_path VARCHAR(500)")) - except Exception: + except OperationalError: pass # Migration: Add plate_detection_enabled column to printers try: await conn.execute(text("ALTER TABLE printers ADD COLUMN plate_detection_enabled BOOLEAN DEFAULT 0")) - except Exception: + except OperationalError: pass # Migration: Add plate detection ROI columns to printers try: await conn.execute(text("ALTER TABLE printers ADD COLUMN plate_detection_roi_x REAL")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE printers ADD COLUMN plate_detection_roi_y REAL")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE printers ADD COLUMN plate_detection_roi_w REAL")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE printers ADD COLUMN plate_detection_roi_h REAL")) - except Exception: + except OperationalError: pass # 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 await conn.execute(text("DROP TABLE smart_plugs")) await conn.execute(text("ALTER TABLE smart_plugs_temp RENAME TO smart_plugs")) - except Exception: + except OperationalError: pass # Migration: Add show_on_printer_card column to smart_plugs try: await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN show_on_printer_card BOOLEAN DEFAULT 1")) - except Exception: + except OperationalError: pass # Migration: Add MQTT smart plug fields (legacy) try: await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_topic VARCHAR(200)")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_path VARCHAR(100)")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_path VARCHAR(100)")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_path VARCHAR(100)")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_multiplier REAL DEFAULT 1.0")) - except Exception: + except OperationalError: pass # Migration: Add enhanced MQTT smart plug fields (separate topics and multipliers) try: await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_topic VARCHAR(200)")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_multiplier REAL DEFAULT 1.0")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_topic VARCHAR(200)")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_multiplier REAL DEFAULT 1.0")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_topic VARCHAR(200)")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_on_value VARCHAR(50)")) - except Exception: + except OperationalError: pass # 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 """) ) - except Exception: + except OperationalError: pass # 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)")) - except Exception: + except OperationalError: pass # Migration: Create user_groups association table @@ -960,65 +961,65 @@ async def run_migrations(conn): ) """) ) - except Exception: + except OperationalError: pass # Migration: Add model-based queue assignment columns to print_queue try: await conn.execute(text("ALTER TABLE print_queue ADD COLUMN target_model VARCHAR(50)")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE print_queue ADD COLUMN required_filament_types TEXT")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE print_queue ADD COLUMN waiting_reason TEXT")) - except Exception: + except OperationalError: pass # Migration: Add nozzle_count column to printers (for dual-extruder detection) try: await conn.execute(text("ALTER TABLE printers ADD COLUMN nozzle_count INTEGER DEFAULT 1")) - except Exception: + except OperationalError: pass # Migration: Add print_hours_offset column to printers (baseline hours adjustment) try: await conn.execute(text("ALTER TABLE printers ADD COLUMN print_hours_offset REAL DEFAULT 0.0")) - except Exception: + except OperationalError: pass # Migration: Add queue notification event columns to notification_providers try: await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_queue_job_added BOOLEAN DEFAULT 0")) - except Exception: + except OperationalError: pass try: await conn.execute( text("ALTER TABLE notification_providers ADD COLUMN on_queue_job_assigned BOOLEAN DEFAULT 0") ) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_queue_job_started BOOLEAN DEFAULT 0")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_queue_job_waiting BOOLEAN DEFAULT 1")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_queue_job_skipped BOOLEAN DEFAULT 1")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_queue_job_failed BOOLEAN DEFAULT 1")) - except Exception: + except OperationalError: pass try: await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_queue_completed BOOLEAN DEFAULT 0")) - except Exception: + except OperationalError: pass # 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( text("ALTER TABLE print_archives ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL") ) - except Exception: + except OperationalError: pass # 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( text("ALTER TABLE print_queue ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL") ) - except Exception: + except OperationalError: pass # 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( text("ALTER TABLE library_files ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL") ) - except Exception: + except OperationalError: pass # Migration: Add target_location column to print_queue for location-based filtering (Issue #220) try: await conn.execute(text("ALTER TABLE print_queue ADD COLUMN target_location VARCHAR(100)")) - except Exception: + except OperationalError: pass # 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 + "%"}, ) - except Exception: + except OperationalError: pass # Create active_print_spoolman table for Spoolman per-filament tracking @@ -1098,7 +1099,7 @@ async def run_migrations(conn): ) """) ) - except Exception: + except OperationalError: pass @@ -1199,7 +1200,7 @@ async def seed_default_groups(): ) session.add(group) groups_created.append(group_name) - logger.info(f"Created default group: {group_name}") + logger.info("Created default group: %s", group_name) else: # Migrate existing group's permissions from old to new format group = existing_groups[group_name] @@ -1218,7 +1219,9 @@ async def seed_default_groups(): if new_perm not in new_permissions: new_permissions.append(new_perm) 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 if group_name == "Administrators": @@ -1261,9 +1264,9 @@ async def seed_default_groups(): if user.role == "admin" and 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: 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() diff --git a/backend/app/main.py b/backend/app/main.py index f8a113cbf..360791f58 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -158,7 +158,7 @@ if app_settings.log_to_file: file_handler.setLevel(log_level) file_handler.setFormatter(logging.Formatter(log_format)) root_logger.addHandler(file_handler) - logging.info(f"Logging to file: {log_file}") + logging.info("Logging to file: %s", log_file) # Reduce noise from third-party libraries in production if not app_settings.debug: @@ -166,7 +166,7 @@ if not app_settings.debug: logging.getLogger("httpcore").setLevel(logging.WARNING) logging.getLogger("httpx").setLevel(logging.WARNING) -logging.info(f"Bambuddy starting - debug={app_settings.debug}, log_level={log_level_str}") +logging.info("Bambuddy starting - debug=%s, log_level=%s", app_settings.debug, log_level_str) from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from sqlalchemy import delete, or_, select @@ -512,13 +512,13 @@ async def on_ams_change(printer_id: int, ams_data: list): try: state = printer_manager.get_status(printer_id) if state: - logger.info(f"[Printer {printer_id}] Broadcasting AMS change via WebSocket") + logger.info("[Printer %s] Broadcasting AMS change via WebSocket", printer_id) await ws_manager.send_printer_status( printer_id, printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)), ) except Exception as e: - logger.warning(f"Failed to broadcast AMS change for printer {printer_id}: {e}") + logger.warning("Failed to broadcast AMS change for printer %s: %s", printer_id, e) try: async with async_session() as db: @@ -551,7 +551,7 @@ async def on_ams_change(printer_id: int, ams_data: list): # Check if Spoolman is reachable if not await client.health_check(): - logger.warning(f"Spoolman not reachable at {spoolman_url}") + logger.warning("Spoolman not reachable at %s", spoolman_url) return # Get printer name for location @@ -575,10 +575,10 @@ async def on_ams_change(printer_id: int, ams_data: list): if result: synced += 1 except Exception as e: - logger.error(f"Error syncing AMS {ams_id} tray {tray.tray_id}: {e}") + logger.error("Error syncing AMS %s tray %s: %s", ams_id, tray.tray_id, e) if synced > 0: - logger.info(f"Auto-synced {synced} AMS trays to Spoolman for printer {printer_id}") + logger.info("Auto-synced %s AMS trays to Spoolman for printer %s", synced, printer_id) except Exception as e: import logging @@ -606,12 +606,12 @@ async def _capture_snapshot_for_notification(printer_id: int, printer, logger) - # Try external camera first if printer.external_camera_enabled and printer.external_camera_url: - logger.info(f"[SNAPSHOT] Capturing from external camera for printer {printer_id}") + logger.info("[SNAPSHOT] Capturing from external camera for printer %s", printer_id) from backend.app.services.external_camera import capture_frame frame_data = await capture_frame(printer.external_camera_url, printer.external_camera_type or "mjpeg") if frame_data and len(frame_data) <= 2_500_000: - logger.info(f"[SNAPSHOT] External camera frame: {len(frame_data)} bytes") + logger.info("[SNAPSHOT] External camera frame: %s bytes", len(frame_data)) return frame_data # Try buffered frame from active stream @@ -622,23 +622,23 @@ async def _capture_snapshot_for_notification(printer_id: int, printer, logger) - buffered_frame = get_buffered_frame(printer_id) if (active_for_printer or active_chamber) and buffered_frame: - logger.info(f"[SNAPSHOT] Using buffered frame for printer {printer_id}: {len(buffered_frame)} bytes") + logger.info("[SNAPSHOT] Using buffered frame for printer %s: %s bytes", printer_id, len(buffered_frame)) if len(buffered_frame) <= 2_500_000: return buffered_frame # Fresh capture from printer camera - logger.info(f"[SNAPSHOT] Capturing fresh frame for printer {printer_id}") + logger.info("[SNAPSHOT] Capturing fresh frame for printer %s", printer_id) from backend.app.services.camera import capture_camera_frame_bytes frame_data = await capture_camera_frame_bytes( printer.ip_address, printer.access_code, printer.model, timeout=15 ) if frame_data and len(frame_data) <= 2_500_000: - logger.info(f"[SNAPSHOT] Fresh camera frame: {len(frame_data)} bytes") + logger.info("[SNAPSHOT] Fresh camera frame: %s bytes", len(frame_data)) return frame_data except Exception as e: - logger.warning(f"[SNAPSHOT] Failed to capture snapshot for printer {printer_id}: {e}") + logger.warning("[SNAPSHOT] Failed to capture snapshot for printer %s: %s", printer_id, e) return None @@ -672,7 +672,7 @@ async def _send_print_start_notification( await notification_service.on_print_start(printer_id, printer_name, data, db, archive_data=archive_data) except Exception as e: - logger.warning(f"Notification on_print_start failed: {e}") + logger.warning("Notification on_print_start failed: %s", e) def _load_objects_from_archive(archive, printer_id: int, logger) -> None: @@ -692,9 +692,9 @@ def _load_objects_from_archive(archive, printer_id: int, logger) -> None: client.state.printable_objects = printable_objects client.state.printable_objects_bbox_all = bbox_all client.state.skipped_objects = [] - logger.info(f"Loaded {len(printable_objects)} printable objects for printer {printer_id}") + logger.info("Loaded %s printable objects for printer %s", len(printable_objects), printer_id) except Exception as e: - logger.debug(f"Failed to extract printable objects from archive: {e}") + logger.debug("Failed to extract printable objects from archive: %s", e) async def on_print_start(printer_id: int, data: dict): @@ -703,7 +703,7 @@ async def on_print_start(printer_id: int, data: dict): logger = logging.getLogger(__name__) - logger.info(f"[CALLBACK] on_print_start called for printer {printer_id}, data keys: {list(data.keys())}") + logger.info("[CALLBACK] on_print_start called for printer %s, data keys: %s", printer_id, list(data.keys())) await ws_manager.send_print_start(printer_id, data) @@ -729,7 +729,7 @@ async def on_print_start(printer_id: int, data: dict): async with async_session() as db: await smart_plug_manager.on_print_start(printer_id, db) except Exception as e: - logger.warning(f"Smart plug on_print_start failed: {e}") + logger.warning("Smart plug on_print_start failed: %s", e) async with async_session() as db: from backend.app.models.printer import Printer @@ -743,7 +743,7 @@ async def on_print_start(printer_id: int, data: dict): f"[PLATE CHECK] printer_id={printer_id}, plate_detection_enabled={printer.plate_detection_enabled if printer else 'NO PRINTER'}" ) if printer and printer.plate_detection_enabled: - logger.info(f"[PLATE CHECK] ENTERING plate detection code for printer {printer_id}") + logger.info("[PLATE CHECK] ENTERING plate detection code for printer %s", printer_id) try: from backend.app.services.plate_detection import check_plate_empty @@ -770,12 +770,12 @@ async def on_print_start(printer_id: int, data: dict): if client and client.state: light_was_off = not client.state.chamber_light if light_was_off: - logger.info(f"[PLATE CHECK] Turning on chamber light for printer {printer_id}") + logger.info("[PLATE CHECK] Turning on chamber light for printer %s", printer_id) client.set_chamber_light(True) # Wait for light to physically turn on and camera to adjust exposure await asyncio.sleep(2.5) - logger.info(f"[PLATE CHECK] Running plate detection for printer {printer_id}") + logger.info("[PLATE CHECK] Running plate detection for printer %s", printer_id) plate_result = await check_plate_empty( printer_id=printer_id, ip_address=printer.ip_address, @@ -790,7 +790,7 @@ async def on_print_start(printer_id: int, data: dict): # Restore chamber light to original state if light_was_off and client: - logger.info(f"[PLATE CHECK] Restoring chamber light to off for printer {printer_id}") + logger.info("[PLATE CHECK] Restoring chamber light to off for printer %s", printer_id) client.set_chamber_light(False) if not plate_result.needs_calibration and not plate_result.is_empty: @@ -802,7 +802,7 @@ async def on_print_start(printer_id: int, data: dict): client = printer_manager.get_client(printer_id) if client: client.pause_print() - logger.info(f"[PLATE CHECK] Print paused for printer {printer_id}") + logger.info("[PLATE CHECK] Print paused for printer %s", printer_id) # Send notification about plate not empty await ws_manager.broadcast( @@ -823,12 +823,12 @@ async def on_print_start(printer_id: int, data: dict): difference_percent=plate_result.difference_percent, ) except Exception as notif_err: - logger.warning(f"[PLATE CHECK] Failed to send notification: {notif_err}") + logger.warning("[PLATE CHECK] Failed to send notification: %s", notif_err) else: - logger.info(f"[PLATE CHECK] Plate is empty for printer {printer_id}, proceeding with print") + logger.info("[PLATE CHECK] Plate is empty for printer %s, proceeding with print", printer_id) except Exception as plate_err: # Don't block print on plate detection errors - logger.warning(f"[PLATE CHECK] Plate detection failed for printer {printer_id}: {plate_err}") + logger.warning("[PLATE CHECK] Plate detection failed for printer %s: %s", printer_id, plate_err) if not printer or not printer.auto_archive: # Send notification without archive data (auto-archive disabled) @@ -843,7 +843,7 @@ async def on_print_start(printer_id: int, data: dict): filename = data.get("filename", "") subtask_name = data.get("subtask_name", "") - logger.info(f"[CALLBACK] Print start detected - filename: {filename}, subtask: {subtask_name}") + logger.info("[CALLBACK] Print start detected - filename: %s, subtask: %s", filename, subtask_name) if not filename and not subtask_name: # Send notification without archive data (no filename) @@ -878,7 +878,7 @@ async def on_print_start(printer_id: int, data: dict): if expected_archive_id: # This is a reprint/scheduled print - use existing archive, don't create new one - logger.info(f"Using expected archive {expected_archive_id} for print (skipping duplicate)") + logger.info("Using expected archive %s for print (skipping duplicate)", expected_archive_id) from backend.app.models.archive import PrintArchive result = await db.execute(select(PrintArchive).where(PrintArchive.id == expected_archive_id)) @@ -897,7 +897,7 @@ async def on_print_start(printer_id: int, data: dict): # Mark as reprint so we add cost on completion _reprint_archives.add(archive.id) - logger.info(f"Marked archive {archive.id} as reprint for cost addition on completion") + logger.info("Marked archive %s as reprint for cost addition on completion", archive.id) # Set up energy tracking try: @@ -908,18 +908,18 @@ async def on_print_start(printer_id: int, data: dict): ) if plug: energy = await _get_plug_energy(plug, db) - logger.info(f"[ENERGY] Energy response from plug: {energy}") + logger.info("[ENERGY] Energy response from plug: %s", energy) if energy and energy.get("total") is not None: _print_energy_start[archive.id] = energy["total"] logger.info( f"[ENERGY] Recorded starting energy for archive {archive.id}: {energy['total']} kWh" ) else: - logger.warning(f"[ENERGY] No 'total' in energy response for archive {archive.id}") + logger.warning("[ENERGY] No 'total' in energy response for archive %s", archive.id) else: - logger.info(f"[ENERGY] No smart plug found for printer {printer_id}") + logger.info("[ENERGY] No smart plug found for printer %s", printer_id) except Exception as e: - logger.warning(f"Failed to record starting energy: {e}") + logger.warning("Failed to record starting energy: %s", e) await ws_manager.send_archive_updated( { @@ -940,7 +940,7 @@ async def on_print_start(printer_id: int, data: dict): try: await _store_spoolman_print_data(printer_id, archive.id, archive.file_path, db, printer_manager) except Exception as e: - logger.warning(f"[SPOOLMAN] Failed to store tracking data: {e}") + logger.warning("[SPOOLMAN] Failed to store tracking data: %s", e) return # Skip creating a new archive @@ -991,7 +991,7 @@ async def on_print_start(printer_id: int, data: dict): f"Recorded starting energy for existing archive {existing_archive.id}: {energy['total']} kWh" ) except Exception as e: - logger.warning(f"Failed to record starting energy for existing archive: {e}") + logger.warning("Failed to record starting energy for existing archive: %s", e) # Send notification with archive data (existing archive) if not notification_sent: archive_data = {"print_time_seconds": existing_archive.print_time_seconds} @@ -1035,7 +1035,7 @@ async def on_print_start(printer_id: int, data: dict): seen = set() possible_names = [x for x in possible_names if not (x in seen or seen.add(x))] - logger.info(f"Trying filenames: {possible_names}") + logger.info("Trying filenames: %s", possible_names) # Try to find and download the 3MF file temp_path = None @@ -1060,7 +1060,7 @@ async def on_print_start(printer_id: int, data: dict): temp_path.parent.mkdir(parents=True, exist_ok=True) for remote_path in remote_paths: - logger.debug(f"Trying FTP download: {remote_path}") + logger.debug("Trying FTP download: %s", remote_path) try: if ftp_retry_enabled: downloaded = await with_ftp_retry( @@ -1086,10 +1086,10 @@ async def on_print_start(printer_id: int, data: dict): ) if downloaded: downloaded_filename = try_filename - logger.info(f"Downloaded: {remote_path}") + logger.info("Downloaded: %s", remote_path) break except Exception as e: - logger.debug(f"FTP download failed for {remote_path}: {e}") + logger.debug("FTP download failed for %s: %s", remote_path, e) if downloaded_filename: break @@ -1098,7 +1098,7 @@ async def on_print_start(printer_id: int, data: dict): # Different printer models use different directory structures if not downloaded_filename and (filename or subtask_name): search_term = (subtask_name or filename).lower().replace(".gcode", "").replace(".3mf", "") - logger.info(f"Direct FTP download failed, searching directories for '{search_term}'") + logger.info("Direct FTP download failed, searching directories for '%s'", search_term) search_dirs = ["/cache", "/model", "/data", "/data/Metadata", "/"] for search_dir in search_dirs: if downloaded_filename: @@ -1118,7 +1118,7 @@ async def on_print_start(printer_id: int, data: dict): fname_normalized = fname.lower().replace(" ", "_") search_normalized = search_term.replace(" ", "_") if fname.endswith(".3mf") and search_normalized in fname_normalized: - logger.info(f"Found matching file in {search_dir}: {fname}") + logger.info("Found matching file in %s: %s", search_dir, fname) temp_path = app_settings.archive_dir / "temp" / fname temp_path.parent.mkdir(parents=True, exist_ok=True) if ftp_retry_enabled: @@ -1141,13 +1141,13 @@ async def on_print_start(printer_id: int, data: dict): ) if downloaded: downloaded_filename = fname - logger.info(f"Found and downloaded from {search_dir}: {fname}") + logger.info("Found and downloaded from %s: %s", search_dir, fname) break except Exception as e: - logger.debug(f"Failed to list {search_dir}: {e}") + logger.debug("Failed to list %s: %s", search_dir, e) if not downloaded_filename or not temp_path: - logger.warning(f"Could not find 3MF file for print: {filename or subtask_name}") + logger.warning("Could not find 3MF file for print: %s", filename or subtask_name) # Create a fallback archive without 3MF data so the print is still tracked # This commonly happens with P1S/A1 printers where FTP has file size limitations try: @@ -1178,7 +1178,7 @@ async def on_print_start(printer_id: int, data: dict): await db.commit() await db.refresh(fallback_archive) - logger.info(f"Created fallback archive {fallback_archive.id} for {print_name} (no 3MF available)") + logger.info("Created fallback archive %s for %s (no 3MF available)", fallback_archive.id, print_name) # Start timelapse session if external camera is enabled if printer.external_camera_enabled and printer.external_camera_url: @@ -1190,7 +1190,7 @@ async def on_print_start(printer_id: int, data: dict): printer.external_camera_url, printer.external_camera_type or "mjpeg", ) - logger.info(f"Started layer timelapse for printer {printer_id}, archive {fallback_archive.id}") + logger.info("Started layer timelapse for printer %s, archive %s", printer_id, fallback_archive.id) # Track as active print _active_prints[(printer_id, fallback_archive.filename)] = fallback_archive.id @@ -1212,7 +1212,7 @@ async def on_print_start(printer_id: int, data: dict): f"[ENERGY] Recorded starting energy for fallback archive {fallback_archive.id}: {energy['total']} kWh" ) except Exception as e: - logger.warning(f"Failed to record starting energy for fallback: {e}") + logger.warning("Failed to record starting energy for fallback: %s", e) # Send WebSocket notification await ws_manager.send_archive_created( @@ -1242,14 +1242,14 @@ async def on_print_start(printer_id: int, data: dict): printer_id, fallback_archive.id, fallback_archive.file_path, db, printer_manager ) except Exception as e: - logger.debug(f"[SPOOLMAN] Could not store tracking for fallback archive: {e}") + logger.debug("[SPOOLMAN] Could not store tracking for fallback archive: %s", e) # Send notification without archive data (file not found) if not notification_sent: await _send_print_start_notification(printer_id, data, logger=logger) return except Exception as e: - logger.error(f"Failed to create fallback archive: {e}") + logger.error("Failed to create fallback archive: %s", e) # Send notification without archive data (file not found) if not notification_sent: await _send_print_start_notification(printer_id, data, logger=logger) @@ -1272,7 +1272,7 @@ async def on_print_start(printer_id: int, data: dict): if subtask_name: _active_prints[(printer_id, f"{subtask_name}.3mf")] = archive.id - logger.info(f"Created archive {archive.id} for {downloaded_filename}") + logger.info("Created archive %s for %s", archive.id, downloaded_filename) # Start timelapse session if external camera is enabled if printer.external_camera_enabled and printer.external_camera_url: @@ -1284,7 +1284,7 @@ async def on_print_start(printer_id: int, data: dict): printer.external_camera_url, printer.external_camera_type or "mjpeg", ) - logger.info(f"Started layer timelapse for printer {printer_id}, archive {archive.id}") + logger.info("Started layer timelapse for printer %s, archive %s", printer_id, archive.id) # Record starting energy from smart plug if available try: @@ -1295,18 +1295,18 @@ async def on_print_start(printer_id: int, data: dict): ) if plug: energy = await _get_plug_energy(plug, db) - logger.info(f"[ENERGY] Auto-archive energy response: {energy}") + logger.info("[ENERGY] Auto-archive energy response: %s", energy) if energy and energy.get("total") is not None: _print_energy_start[archive.id] = energy["total"] logger.info( f"[ENERGY] Recorded starting energy for archive {archive.id}: {energy['total']} kWh" ) else: - logger.warning(f"[ENERGY] No 'total' in energy response for archive {archive.id}") + logger.warning("[ENERGY] No 'total' in energy response for archive %s", archive.id) else: - logger.info(f"[ENERGY] No smart plug found for printer {printer_id}") + logger.info("[ENERGY] No smart plug found for printer %s", printer_id) except Exception as e: - logger.warning(f"Failed to record starting energy: {e}") + logger.warning("Failed to record starting energy: %s", e) await ws_manager.send_archive_created( { @@ -1352,15 +1352,17 @@ async def on_print_start(printer_id: int, data: dict): client.state.printable_objects = printable_objects client.state.printable_objects_bbox_all = bbox_all client.state.skipped_objects = [] # Reset skipped objects for new print - logger.info(f"Loaded {len(printable_objects)} printable objects for printer {printer_id}") + logger.info( + "Loaded %s printable objects for printer %s", len(printable_objects), printer_id + ) except Exception as e: - logger.debug(f"Failed to extract printable objects: {e}") + logger.debug("Failed to extract printable objects: %s", e) # Store Spoolman tracking data for per-filament usage reporting try: await _store_spoolman_print_data(printer_id, archive.id, archive.file_path, db, printer_manager) except Exception as e: - logger.warning(f"[SPOOLMAN] Failed to store tracking data: {e}") + logger.warning("[SPOOLMAN] Failed to store tracking data: %s", e) finally: if temp_path and temp_path.exists(): temp_path.unlink() @@ -1399,13 +1401,13 @@ async def _scan_for_timelapse_with_retries(archive_id: int): archive = await service.get_archive(archive_id) if not archive: - logger.warning(f"[TIMELAPSE] Archive {archive_id} not found, stopping retries") + logger.warning("[TIMELAPSE] Archive %s not found, stopping retries", archive_id) return if archive.timelapse_path: - logger.info(f"[TIMELAPSE] Archive {archive_id} already has timelapse attached, stopping retries") + logger.info("[TIMELAPSE] Archive %s already has timelapse attached, stopping retries", archive_id) return if not archive.printer_id: - logger.warning(f"[TIMELAPSE] Archive {archive_id} has no printer, stopping retries") + logger.warning("[TIMELAPSE] Archive %s has no printer, stopping retries", archive_id) return # Get printer @@ -1413,7 +1415,7 @@ async def _scan_for_timelapse_with_retries(archive_id: int): printer = result.scalar_one_or_none() if not printer: - logger.warning(f"[TIMELAPSE] Printer not found for archive {archive_id}, stopping retries") + logger.warning("[TIMELAPSE] Printer not found for archive %s, stopping retries", archive_id) return # Scan timelapse directory on printer @@ -1426,59 +1428,61 @@ async def _scan_for_timelapse_with_retries(archive_id: int): if found_files: files = found_files found_path = timelapse_path - logger.info(f"[TIMELAPSE] Attempt {attempt}: Found {len(files)} files in {timelapse_path}") + logger.info( + "[TIMELAPSE] Attempt %s: Found %s files in %s", attempt, len(files), timelapse_path + ) break except Exception as e: - logger.debug(f"[TIMELAPSE] Path {timelapse_path} failed: {e}") + logger.debug("[TIMELAPSE] Path %s failed: %s", timelapse_path, e) continue if not files: - logger.info(f"[TIMELAPSE] Attempt {attempt}: No timelapse files found on printer, will retry") + logger.info("[TIMELAPSE] Attempt %s: No timelapse files found on printer, will retry", attempt) continue mp4_files = [f for f in files if not f.get("is_directory") and f.get("name", "").endswith(".mp4")] # Log ALL mp4 files found for debugging - logger.info(f"[TIMELAPSE] Attempt {attempt}: Found {len(mp4_files)} MP4 files in {found_path}") + logger.info("[TIMELAPSE] Attempt %s: Found %s MP4 files in %s", attempt, len(mp4_files), found_path) for f in mp4_files[:5]: # Log first 5 - logger.info(f"[TIMELAPSE] - {f.get('name')}, mtime={f.get('mtime')}") + logger.info("[TIMELAPSE] - %s, mtime=%s", f.get("name"), f.get("mtime")) if not mp4_files: - logger.info(f"[TIMELAPSE] Attempt {attempt}: No MP4 files found, will retry") + logger.info("[TIMELAPSE] Attempt %s: No MP4 files found, will retry", attempt) continue # Sort by mtime descending to get most recent file mp4_files_with_mtime = [f for f in mp4_files if f.get("mtime")] if not mp4_files_with_mtime: - logger.info(f"[TIMELAPSE] Attempt {attempt}: No MP4 files with mtime found, will retry") + logger.info("[TIMELAPSE] Attempt %s: No MP4 files with mtime found, will retry", attempt) continue mp4_files_with_mtime.sort(key=lambda x: x.get("mtime"), reverse=True) most_recent = mp4_files_with_mtime[0] file_name = most_recent.get("name") - logger.info(f"[TIMELAPSE] Attempt {attempt}: Most recent file: {file_name}") + logger.info("[TIMELAPSE] Attempt %s: Most recent file: %s", attempt, file_name) # Since we KNOW timelapse was active (from MQTT), just grab the most recent file remote_path = most_recent.get("path") or f"/timelapse/{file_name}" - logger.info(f"[TIMELAPSE] Downloading {file_name} for archive {archive_id}") + logger.info("[TIMELAPSE] Downloading %s for archive %s", file_name, archive_id) timelapse_data = await download_file_bytes_async(printer.ip_address, printer.access_code, remote_path) if timelapse_data: success = await service.attach_timelapse(archive_id, timelapse_data, file_name) if success: - logger.info(f"[TIMELAPSE] Successfully attached timelapse to archive {archive_id}") + logger.info("[TIMELAPSE] Successfully attached timelapse to archive %s", archive_id) await ws_manager.send_archive_updated({"id": archive_id, "timelapse_attached": True}) return # Success! else: - logger.warning(f"[TIMELAPSE] Failed to attach timelapse to archive {archive_id}") + logger.warning("[TIMELAPSE] Failed to attach timelapse to archive %s", archive_id) else: - logger.warning(f"[TIMELAPSE] Attempt {attempt}: Failed to download, will retry") + logger.warning("[TIMELAPSE] Attempt %s: Failed to download, will retry", attempt) except Exception as e: - logger.warning(f"[TIMELAPSE] Attempt {attempt} failed with error: {e}") + logger.warning("[TIMELAPSE] Attempt %s failed with error: %s", attempt, e) - logger.warning(f"[TIMELAPSE] All {len(retry_delays)} attempts exhausted for archive {archive_id}, giving up") + logger.warning("[TIMELAPSE] All %s attempts exhausted for archive %s, giving up", len(retry_delays), archive_id) async def on_print_complete(printer_id: int, data: dict): @@ -1491,9 +1495,9 @@ async def on_print_complete(printer_id: int, data: dict): def log_timing(section: str): elapsed = time.time() - start_time - logger.info(f"[TIMING] {section}: {elapsed:.3f}s elapsed") + logger.info("[TIMING] %s: %.3fs elapsed", section, elapsed) - logger.info(f"[CALLBACK] on_print_complete started for printer {printer_id}") + logger.info("[CALLBACK] on_print_complete started for printer %s", printer_id) try: ws_data = { @@ -1505,7 +1509,7 @@ async def on_print_complete(printer_id: int, data: dict): await ws_manager.send_print_complete(printer_id, ws_data) log_timing("WebSocket send_print_complete") except Exception as e: - logger.warning(f"[CALLBACK] WebSocket send_print_complete failed: {e}") + logger.warning("[CALLBACK] WebSocket send_print_complete failed: %s", e) # Clear current print user tracking (Issue #206) printer_manager.clear_current_print_user(printer_id) @@ -1532,7 +1536,7 @@ async def on_print_complete(printer_id: int, data: dict): logger.warning("Print complete without filename or subtask_name") return - logger.info(f"Print complete - filename: {filename}, subtask: {subtask_name}, status: {data.get('status')}") + logger.info("Print complete - filename: %s, subtask: %s, status: %s", filename, subtask_name, data.get("status")) # Build list of possible keys to try (matching how they were registered in on_print_start) possible_keys = [] @@ -1572,13 +1576,13 @@ async def on_print_complete(printer_id: int, data: dict): possible_keys.append((printer_id, filename)) # Find the archive for this print - logger.info(f"Looking for archive in _active_prints, keys to try: {possible_keys[:5]}...") - logger.info(f"Current _active_prints: {list(_active_prints.keys())}") + logger.info("Looking for archive in _active_prints, keys to try: %s...", possible_keys[:5]) + logger.info("Current _active_prints: %s", list(_active_prints.keys())) archive_id = None for key in possible_keys: archive_id = _active_prints.pop(key, None) if archive_id: - logger.info(f"Found archive {archive_id} with key {key}") + logger.info("Found archive %s with key %s", archive_id, key) # Also clean up any other keys pointing to this archive keys_to_remove = [k for k, v in _active_prints.items() if v == archive_id] for k in keys_to_remove: @@ -1608,7 +1612,7 @@ async def on_print_complete(printer_id: int, data: dict): archive = result.scalar_one_or_none() if archive: archive_id = archive.id - logger.info(f"Found archive {archive_id} by subtask_name match: {subtask_name}") + logger.info("Found archive %s by subtask_name match: %s", archive_id, subtask_name) # Also try by filename if not archive_id and filename: @@ -1625,13 +1629,13 @@ async def on_print_complete(printer_id: int, data: dict): archive_id = archive.id if not archive_id: - logger.warning(f"Could not find archive for print complete: filename={filename}, subtask={subtask_name}") + logger.warning("Could not find archive for print complete: filename=%s, subtask=%s", filename, subtask_name) return log_timing("Archive lookup") # Update archive status - logger.info(f"[ARCHIVE] Updating archive {archive_id} status...") + logger.info("[ARCHIVE] Updating archive %s status...", archive_id) try: async with async_session() as db: service = ArchiveService(db) @@ -1646,7 +1650,7 @@ async def on_print_complete(printer_id: int, data: dict): # Try to determine failure reason from HMS errors hms_errors = data.get("hms_errors", []) if hms_errors: - logger.info(f"[ARCHIVE] HMS errors at failure: {hms_errors}") + logger.info("[ARCHIVE] HMS errors at failure: %s", hms_errors) # Map known HMS error modules to failure reasons # Module 0x07 = Filament, 0x0C = MC (Motion Controller), etc. for err in hms_errors: @@ -1661,7 +1665,7 @@ async def on_print_complete(printer_id: int, data: dict): failure_reason = "Clogged nozzle" break if failure_reason: - logger.info(f"[ARCHIVE] Detected failure_reason from HMS: {failure_reason}") + logger.info("[ARCHIVE] Detected failure_reason from HMS: %s", failure_reason) else: logger.info("[ARCHIVE] No HMS errors available to determine failure reason") @@ -1671,16 +1675,18 @@ async def on_print_complete(printer_id: int, data: dict): completed_at=datetime.now() if status in ("completed", "failed", "aborted") else None, failure_reason=failure_reason, ) - logger.info(f"[ARCHIVE] Archive {archive_id} status updated to {status}, failure_reason={failure_reason}") + logger.info( + "[ARCHIVE] Archive %s status updated to %s, failure_reason=%s", archive_id, status, failure_reason + ) # Add cost for reprints (first prints have cost set in archive_print()) if status == "completed" and archive_id in _reprint_archives: _reprint_archives.discard(archive_id) try: await service.add_reprint_cost(archive_id) - logger.info(f"[ARCHIVE] Added reprint cost for archive {archive_id}") + logger.info("[ARCHIVE] Added reprint cost for archive %s", archive_id) except Exception as e: - logger.warning(f"[ARCHIVE] Failed to add reprint cost for archive {archive_id}: {e}") + logger.warning("[ARCHIVE] Failed to add reprint cost for archive %s: %s", archive_id, e) await ws_manager.send_archive_updated( { @@ -1688,7 +1694,7 @@ async def on_print_complete(printer_id: int, data: dict): "status": status, } ) - logger.info(f"[ARCHIVE] WebSocket notification sent for archive {archive_id}") + logger.info("[ARCHIVE] WebSocket notification sent for archive %s", archive_id) # MQTT relay - publish archive updated try: @@ -1700,7 +1706,7 @@ async def on_print_complete(printer_id: int, data: dict): except Exception: pass # Don't fail if MQTT fails except Exception as e: - logger.error(f"[ARCHIVE] Failed to update archive {archive_id} status: {e}", exc_info=True) + logger.error("[ARCHIVE] Failed to update archive %s status: %s", archive_id, e, exc_info=True) # Continue with other operations even if archive update fails log_timing("Archive status update") @@ -1711,14 +1717,14 @@ async def on_print_complete(printer_id: int, data: dict): await _report_spoolman_usage(printer_id, archive_id) log_timing("Spoolman usage report") except Exception as e: - logger.warning(f"Spoolman usage reporting failed: {e}") + logger.warning("Spoolman usage reporting failed: %s", e) else: # Report partial usage if tracking data exists (only stored when weight sync is disabled) try: async with async_session() as db: await _cleanup_spoolman_tracking(printer_id, archive_id, db) except Exception as e: - logger.debug(f"[SPOOLMAN] Cleanup failed: {e}") + logger.debug("[SPOOLMAN] Cleanup failed: %s", e) # Run slow operations as background tasks to avoid blocking the event loop # These operations can take 5-10+ seconds and would freeze the UI if awaited @@ -1727,20 +1733,20 @@ async def on_print_complete(printer_id: int, data: dict): async def _background_energy_calculation(): """Calculate and save energy usage in background.""" try: - logger.info(f"[ENERGY-BG] Starting energy calculation for archive {archive_id}") + logger.info("[ENERGY-BG] Starting energy calculation for archive %s", archive_id) async with async_session() as db: plug_result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id)) plug = plug_result.scalar_one_or_none() if plug: energy = await _get_plug_energy(plug, db) - logger.info(f"[ENERGY-BG] Energy response: {energy}") + logger.info("[ENERGY-BG] Energy response: %s", energy) energy_used = None if starting_kwh is not None and energy and energy.get("total") is not None: ending_kwh = energy["total"] energy_used = round(ending_kwh - starting_kwh, 4) - logger.info(f"[ENERGY-BG] Per-print energy: {energy_used} kWh") + logger.info("[ENERGY-BG] Per-print energy: %s kWh", energy_used) if energy_used is not None and energy_used >= 0: from backend.app.api.routes.settings import get_setting @@ -1757,16 +1763,16 @@ async def on_print_complete(printer_id: int, data: dict): archive.energy_kwh = energy_used archive.energy_cost = energy_cost await db.commit() - logger.info(f"[ENERGY-BG] Saved: {energy_used} kWh, cost={energy_cost}") + logger.info("[ENERGY-BG] Saved: %s kWh, cost=%s", energy_used, energy_cost) else: - logger.info(f"[ENERGY-BG] No smart plug for printer {printer_id}") + logger.info("[ENERGY-BG] No smart plug for printer %s", printer_id) except Exception as e: - logger.warning(f"[ENERGY-BG] Failed: {e}") + logger.warning("[ENERGY-BG] Failed: %s", e) async def _background_finish_photo() -> str | None: """Capture finish photo in background. Returns photo filename if captured.""" try: - logger.info(f"[PHOTO-BG] Starting finish photo capture for archive {archive_id}") + logger.info("[PHOTO-BG] Starting finish photo capture for archive %s", archive_id) from backend.app.api.routes.camera import _active_chamber_streams, _active_streams, get_buffered_frame @@ -1810,7 +1816,7 @@ async def on_print_complete(printer_id: int, data: dict): photo_filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg" photo_path = photos_dir / photo_filename await asyncio.to_thread(photo_path.write_bytes, frame_data) - logger.info(f"[PHOTO-BG] Saved external camera frame: {photo_filename}") + logger.info("[PHOTO-BG] Saved external camera frame: %s", photo_filename) else: # Check if camera stream is active - use buffered frame to avoid freeze # Check both RTSP streams (_active_streams) and chamber image streams (_active_chamber_streams) @@ -1829,7 +1835,7 @@ async def on_print_complete(printer_id: int, data: dict): photo_filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg" photo_path = photos_dir / photo_filename await asyncio.to_thread(photo_path.write_bytes, buffered_frame) - logger.info(f"[PHOTO-BG] Saved buffered frame: {photo_filename}") + logger.info("[PHOTO-BG] Saved buffered frame: %s", photo_filename) else: # No active stream - capture new frame from backend.app.services.camera import capture_finish_photo @@ -1847,11 +1853,11 @@ async def on_print_complete(printer_id: int, data: dict): photos.append(photo_filename) archive.photos = photos await db.commit() - logger.info(f"[PHOTO-BG] Saved: {photo_filename}") + logger.info("[PHOTO-BG] Saved: %s", photo_filename) return photo_filename return None except Exception as e: - logger.warning(f"[PHOTO-BG] Failed: {e}") + logger.warning("[PHOTO-BG] Failed: %s", e) return None asyncio.create_task(_background_energy_calculation()) @@ -1865,17 +1871,19 @@ async def on_print_complete(printer_id: int, data: dict): async def _background_smart_plug(): """Handle smart plug automation in background.""" try: - logger.info(f"[AUTO-OFF-BG] Starting smart plug automation for printer {printer_id}") + logger.info("[AUTO-OFF-BG] Starting smart plug automation for printer %s", printer_id) async with async_session() as db: await smart_plug_manager.on_print_complete(printer_id, print_status, db) logger.info("[AUTO-OFF-BG] Completed") except Exception as e: - logger.warning(f"[AUTO-OFF-BG] Failed: {e}") + logger.warning("[AUTO-OFF-BG] Failed: %s", e) async def _background_notifications(finish_photo_filename: str | None = None): """Send print complete notifications in background.""" try: - logger.info(f"[NOTIFY-BG] Starting notifications for printer {printer_id}, photo={finish_photo_filename}") + logger.info( + "[NOTIFY-BG] Starting notifications for printer %s, photo=%s", printer_id, finish_photo_filename + ) async with async_session() as db: from backend.app.models.archive import PrintArchive from backend.app.models.printer import Printer @@ -1924,28 +1932,28 @@ async def on_print_complete(printer_id: int, data: dict): photo_bytes = await asyncio.to_thread(photo_path.read_bytes) if len(photo_bytes) <= 2_500_000: archive_data["image_data"] = photo_bytes - logger.info(f"[NOTIFY-BG] Loaded finish photo bytes: {len(photo_bytes)} bytes") + logger.info("[NOTIFY-BG] Loaded finish photo bytes: %s bytes", len(photo_bytes)) else: logger.warning( f"[NOTIFY-BG] Finish photo too large for attachment: " f"{len(photo_bytes)} bytes" ) except Exception as e: - logger.warning(f"[NOTIFY-BG] Failed to read finish photo bytes: {e}") + logger.warning("[NOTIFY-BG] Failed to read finish photo bytes: %s", e) await notification_service.on_print_complete( printer_id, printer_name, print_status, data, db, archive_data=archive_data ) logger.info("[NOTIFY-BG] Completed") except Exception as e: - logger.warning(f"[NOTIFY-BG] Failed: {e}") + logger.warning("[NOTIFY-BG] Failed: %s", e) async def _background_maintenance_check(): """Check for maintenance due in background.""" if print_status != "completed": return try: - logger.info(f"[MAINT-BG] Starting maintenance check for printer {printer_id}") + logger.info("[MAINT-BG] Starting maintenance check for printer %s", printer_id) async with async_session() as db: from backend.app.models.printer import Printer @@ -1964,7 +1972,7 @@ async def on_print_complete(printer_id: int, data: dict): if items_needing_attention: await notification_service.on_maintenance_due(printer_id, printer_name, items_needing_attention, db) - logger.info(f"[MAINT-BG] Sent notification: {len(items_needing_attention)} items need attention") + logger.info("[MAINT-BG] Sent notification: %s items need attention", len(items_needing_attention)) # MQTT relay - publish maintenance alerts for item in items_needing_attention: @@ -1981,7 +1989,7 @@ async def on_print_complete(printer_id: int, data: dict): else: logger.info("[MAINT-BG] Completed (no items need attention)") except Exception as e: - logger.warning(f"[MAINT-BG] Failed: {e}") + logger.warning("[MAINT-BG] Failed: %s", e) asyncio.create_task(_background_smart_plug()) asyncio.create_task(_background_maintenance_check()) @@ -1991,10 +1999,10 @@ async def on_print_complete(printer_id: int, data: dict): """Wait for photo capture, then send notification with photo URL.""" try: finish_photo = await photo_task - logger.info(f"[PHOTO-NOTIFY] Photo task returned: {finish_photo}") + logger.info("[PHOTO-NOTIFY] Photo task returned: %s", finish_photo) await _background_notifications(finish_photo) except Exception as e: - logger.warning(f"[PHOTO-NOTIFY] Failed: {e}") + logger.warning("[PHOTO-NOTIFY] Failed: %s", e) # Still try to send notification without photo await _background_notifications(None) @@ -2009,10 +2017,10 @@ async def on_print_complete(printer_id: int, data: dict): try: if print_status == "completed": - logger.info(f"[LAYER-TL] Stitching layer timelapse for printer {printer_id}") + logger.info("[LAYER-TL] Stitching layer timelapse for printer %s", printer_id) timelapse_path = await tl_complete(printer_id) if timelapse_path and archive_id: - logger.info(f"[LAYER-TL] Attaching timelapse {timelapse_path} to archive {archive_id}") + logger.info("[LAYER-TL] Attaching timelapse %s to archive %s", timelapse_path, archive_id) async with async_session() as db: service = ArchiveService(db) timelapse_data = await asyncio.to_thread(timelapse_path.read_bytes) @@ -2026,9 +2034,11 @@ async def on_print_complete(printer_id: int, data: dict): else: # Print failed or cancelled - cancel timelapse session cancel_session(printer_id) - logger.info(f"[LAYER-TL] Cancelled layer timelapse for printer {printer_id} (status: {print_status})") + logger.info( + "[LAYER-TL] Cancelled layer timelapse for printer %s (status: %s)", printer_id, print_status + ) except Exception as e: - logger.warning(f"[LAYER-TL] Failed: {e}") + logger.warning("[LAYER-TL] Failed: %s", e) # Try to cancel session on error try: cancel_session(printer_id) @@ -2040,7 +2050,7 @@ async def on_print_complete(printer_id: int, data: dict): # Auto-scan for timelapse if recording was active during the print if archive_id and data.get("timelapse_was_active") and data.get("status") == "completed": - logger.info(f"[TIMELAPSE] Timelapse was active during print, scheduling auto-scan for archive {archive_id}") + logger.info("[TIMELAPSE] Timelapse was active during print, scheduling auto-scan for archive %s", archive_id) # Schedule timelapse scan as background task with retries # The printer needs time to encode the video after print completion asyncio.create_task(_scan_for_timelapse_with_retries(archive_id)) @@ -2065,7 +2075,7 @@ async def on_print_complete(printer_id: int, data: dict): queue_item.status = status queue_item.completed_at = datetime.now() await db.commit() - logger.info(f"Updated queue item {queue_item.id} status to {status}") + logger.info("Updated queue item %s status to %s", queue_item.id, status) # MQTT relay - publish queue job completed try: @@ -2113,7 +2123,7 @@ async def on_print_complete(printer_id: int, data: dict): result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id)) plug = result.scalar_one_or_none() if plug and plug.enabled: - logger.info(f"Auto-off requested for printer {printer_id}, waiting for cooldown...") + logger.info("Auto-off requested for printer %s, waiting for cooldown...", printer_id) async def cooldown_and_poweroff(pid: int, plug_id: int): # Wait for nozzle to cool down @@ -2125,9 +2135,9 @@ async def on_print_complete(printer_id: int, data: dict): if p and p.enabled: success = await tasmota_service.turn_off(p) if success: - logger.info(f"Powered off printer {pid} via smart plug '{p.name}'") + logger.info("Powered off printer %s via smart plug '%s'", pid, p.name) else: - logger.warning(f"Failed to power off printer {pid} via smart plug") + logger.warning("Failed to power off printer %s via smart plug", pid) asyncio.create_task(cooldown_and_poweroff(printer_id, plug.id)) except Exception as e: @@ -2136,7 +2146,7 @@ async def on_print_complete(printer_id: int, data: dict): logging.getLogger(__name__).warning(f"Queue item update failed: {e}") log_timing("Queue item update") - logger.info(f"[CALLBACK] on_print_complete finished for printer {printer_id}, archive {archive_id}") + logger.info("[CALLBACK] on_print_complete finished for printer %s, archive %s", printer_id, archive_id) # AMS sensor history recording @@ -2271,7 +2281,7 @@ async def record_ams_history(): printer.id, printer.name, ams_label, humidity, humidity_threshold, db ) except Exception as e: - logger.warning(f"Failed to send humidity alarm: {e}") + logger.warning("Failed to send humidity alarm: %s", e) # Check temperature alarm (only if above threshold) if temperature is not None and temperature > temp_threshold: @@ -2297,11 +2307,11 @@ async def record_ams_history(): printer.id, printer.name, ams_label, temperature, temp_threshold, db ) except Exception as e: - logger.warning(f"Failed to send temperature alarm: {e}") + logger.warning("Failed to send temperature alarm: %s", e) await db.commit() if recorded_count > 0: - logger.info(f"Recorded {recorded_count} AMS sensor history entries") + logger.info("Recorded %s AMS sensor history entries", recorded_count) # Periodic cleanup of old data (every ~288 recordings = ~24 hours at 5min interval) global _ams_cleanup_counter @@ -2329,7 +2339,7 @@ async def record_ams_history(): except asyncio.CancelledError: break except Exception as e: - logger.warning(f"AMS history recording failed: {e}") + logger.warning("AMS history recording failed: %s", e) await asyncio.sleep(60) # Wait a bit before retrying @@ -2382,10 +2392,10 @@ async def track_printer_runtime(): # Get current state from printer manager state = printer_manager.get_status(printer.id) if not state: - logger.debug(f"[{printer.name}] Runtime tracking: no state available") + logger.debug("[%s] Runtime tracking: no state available", printer.name) continue if not state.connected: - logger.debug(f"[{printer.name}] Runtime tracking: not connected") + logger.debug("[%s] Runtime tracking: not connected", printer.name) continue # Check if printer is in an active state (RUNNING or PAUSE) @@ -2404,7 +2414,7 @@ async def track_printer_runtime(): else: # First time seeing printer active - need to commit to save timestamp needs_commit = True - logger.debug(f"[{printer.name}] Runtime tracking: first active detection") + logger.debug("[%s] Runtime tracking: first active detection", printer.name) printer.last_runtime_update = now else: @@ -2419,13 +2429,13 @@ async def track_printer_runtime(): if needs_commit: await db.commit() if updated_count > 0: - logger.debug(f"Updated runtime for {updated_count} printer(s)") + logger.debug("Updated runtime for %s printer(s)", updated_count) except asyncio.CancelledError: logger.info("Runtime tracking cancelled") break except Exception as e: - logger.warning(f"Runtime tracking failed: {e}") + logger.warning("Runtime tracking failed: %s", e) await asyncio.sleep(RUNTIME_TRACKING_INTERVAL) @@ -2504,7 +2514,7 @@ async def lifespan(app: FastAPI): multiplier=plug.mqtt_multiplier or 1.0, ) if mqtt_plugs: - logging.info(f"Restored {len(mqtt_plugs)} MQTT smart plug subscriptions") + logging.info("Restored %s MQTT smart plug subscriptions", len(mqtt_plugs)) # Connect to all active printers async with async_session() as db: @@ -2521,13 +2531,13 @@ async def lifespan(app: FastAPI): try: client = await init_spoolman_client(spoolman_url) if await client.health_check(): - logging.info(f"Auto-connected to Spoolman at {spoolman_url}") + logging.info("Auto-connected to Spoolman at %s", spoolman_url) # Ensure the 'tag' extra field exists for RFID/UUID storage await client.ensure_tag_extra_field() else: - logging.warning(f"Spoolman at {spoolman_url} is not reachable") + logging.warning("Spoolman at %s is not reachable", spoolman_url) except Exception as e: - logging.warning(f"Failed to auto-connect to Spoolman: {e}") + logging.warning("Failed to auto-connect to Spoolman: %s", e) # Start the print scheduler asyncio.create_task(print_scheduler.run()) @@ -2594,11 +2604,11 @@ async def lifespan(app: FastAPI): remote_interface_ip=vp_remote_iface, ) if vp_mode == "proxy": - logging.info(f"Virtual printer proxy started (target={vp_target_ip})") + logging.info("Virtual printer proxy started (target=%s)", vp_target_ip) else: - logging.info(f"Virtual printer started (model={vp_model or 'default'})") + logging.info("Virtual printer started (model=%s)", vp_model or "default") except Exception as e: - logging.warning(f"Failed to start virtual printer: {e}") + logging.warning("Failed to start virtual printer: %s", e) yield diff --git a/backend/app/services/archive.py b/backend/app/services/archive.py index 352d142dc..a7a268319 100644 --- a/backend/app/services/archive.py +++ b/backend/app/services/archive.py @@ -8,6 +8,7 @@ from datetime import datetime from pathlib import Path from defusedxml import ElementTree as ET +from defusedxml.ElementTree import ParseError as XMLParseError from sqlalchemy import and_, or_, select from sqlalchemy.ext.asyncio import AsyncSession @@ -56,7 +57,7 @@ class ThreeMFParser: self.metadata.pop("_slice_filament_type", None) self.metadata.pop("_slice_filament_color", None) self.metadata.pop("_plate_index", None) - except Exception: + except (KeyError, ValueError, zipfile.BadZipFile, XMLParseError, UnicodeDecodeError): pass return self.metadata @@ -151,7 +152,7 @@ class ThreeMFParser: self.metadata["_slice_filament_type"] = ", ".join(types) if colors: self.metadata["_slice_filament_color"] = ",".join(colors) - except Exception: + except (KeyError, ValueError, XMLParseError, UnicodeDecodeError): pass def _parse_project_settings(self, zf: zipfile.ZipFile): @@ -165,7 +166,7 @@ class ThreeMFParser: self._extract_print_settings(data) except json.JSONDecodeError: pass - except Exception: + except (KeyError, ValueError, UnicodeDecodeError): pass def _parse_gcode_header(self, zf: zipfile.ZipFile): @@ -197,7 +198,7 @@ class ThreeMFParser: raw_model = match.group(1).strip() self.metadata["sliced_for_model"] = normalize_printer_model(raw_model) - except Exception: + except (KeyError, ValueError, UnicodeDecodeError): pass def _extract_filament_info(self, data: dict): @@ -238,7 +239,7 @@ class ThreeMFParser: if non_support_colors: self.metadata["filament_color"] = ",".join(non_support_colors) - except Exception: + except (KeyError, ValueError, TypeError, IndexError): pass def _extract_print_settings(self, data: dict): @@ -285,7 +286,7 @@ class ThreeMFParser: from backend.app.utils.printer_models import normalize_printer_model self.metadata["sliced_for_model"] = normalize_printer_model(data["printer_model"]) - except Exception: + except (KeyError, ValueError, TypeError): pass def _extract_settings_from_content(self, content: str): @@ -309,7 +310,7 @@ class ThreeMFParser: value_end = content.find("}", value_start) value = content[value_start:value_end].strip().strip('"') self.metadata[key] = converter(value) - except Exception: + except (ValueError, TypeError): pass def _parse_3dmodel(self, zf: zipfile.ZipFile): @@ -356,7 +357,7 @@ class ThreeMFParser: if "Title" in makerworld_fields: self.metadata["print_name"] = makerworld_fields["Title"] - except Exception: + except (KeyError, ValueError, UnicodeDecodeError): pass def _extract_thumbnail(self, zf: zipfile.ZipFile): @@ -482,7 +483,7 @@ def extract_printable_objects_from_3mf( except ValueError: pass - except Exception: + except (KeyError, ValueError, zipfile.BadZipFile, XMLParseError, UnicodeDecodeError): pass 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) return result @@ -628,7 +629,7 @@ class ProjectPageParser: } content_type = content_types.get(ext, "application/octet-stream") return (data, content_type) - except Exception: + except (KeyError, zipfile.BadZipFile, OSError): pass return None @@ -690,7 +691,7 @@ class ProjectPageParser: shutil.move(tmp_path, self.file_path) return True - except Exception: + except (zipfile.BadZipFile, OSError, UnicodeDecodeError, KeyError, ValueError): # Clean up temp file if it exists if "tmp_path" in locals() and tmp_path.exists(): tmp_path.unlink() @@ -894,7 +895,7 @@ class ArchiveService: printable_objects = metadata.get("printable_objects") if printable_objects and isinstance(printable_objects, dict): 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 archive = PrintArchive( @@ -998,7 +999,7 @@ class ArchiveService: archive.cost = round(archive.cost + additional_cost, 2) 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 async def list_archives( diff --git a/backend/app/services/bambu_cloud.py b/backend/app/services/bambu_cloud.py index 647086625..e33a9f0a2 100644 --- a/backend/app/services/bambu_cloud.py +++ b/backend/app/services/bambu_cloud.py @@ -109,7 +109,7 @@ class BambuCloudService: return {"success": False, "needs_verification": False, "message": error_msg} 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}") async def verify_code(self, email: str, code: str) -> dict: @@ -127,7 +127,7 @@ class BambuCloudService: ) 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: self._set_tokens(data) @@ -136,7 +136,7 @@ class BambuCloudService: return {"success": False, "message": data.get("message", "Verification failed")} except Exception as e: - logger.error(f"Email verification failed: {e}") + logger.error("Email verification failed: %s", e) raise BambuCloudAuthError(f"Verification failed: {e}") async def verify_totp(self, tfa_key: str, code: str) -> dict: @@ -178,13 +178,13 @@ class BambuCloudService: # Handle empty response 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."} try: data = response.json() 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"} # Token might be in accessToken, token field, or cookies @@ -215,7 +215,7 @@ class BambuCloudService: return {"success": False, "message": error_msg} 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 {"success": False, "message": f"TOTP verification error: {e}"} diff --git a/backend/app/services/bambu_ftp.py b/backend/app/services/bambu_ftp.py index f3dfd09b4..b90ddea31 100644 --- a/backend/app/services/bambu_ftp.py +++ b/backend/app/services/bambu_ftp.py @@ -117,7 +117,7 @@ class BambuFTPClient: def cache_mode(cls, ip_address: str, mode: str): """Cache the working FTP mode for a printer.""" 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: """Determine if we should use prot_c (clear) mode.""" @@ -154,25 +154,25 @@ class BambuFTPClient: self._ftp.set_pasv(True) # Log welcome message for debugging 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( f"FTP connected successfully to {self.ip_address} (model={self.printer_model}, prot_c={use_prot_c})" ) return True 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 return False 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 return False 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 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 return False @@ -181,7 +181,7 @@ class BambuFTPClient: if self._ftp: try: self._ftp.quit() - except Exception: + except (OSError, ftplib.error_reply): pass self._ftp = None @@ -238,9 +238,9 @@ class BambuFTPClient: if mtime: file_entry["mtime"] = mtime 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 @@ -253,7 +253,7 @@ class BambuFTPClient: buffer = BytesIO() self._ftp.retrbinary(f"RETR {remote_path}", buffer.write) return buffer.getvalue() - except Exception: + except (OSError, ftplib.error_reply): return None def download_to_file(self, remote_path: str, local_path: Path) -> bool: @@ -269,16 +269,16 @@ class BambuFTPClient: f.flush() os.fsync(f.fileno()) 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 - except Exception as e: + except (OSError, ftplib.error_reply) as e: # 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 if local_path.exists(): try: local_path.unlink() - except Exception: + except OSError: pass return False @@ -301,10 +301,10 @@ class BambuFTPClient: # Try to get current directory try: 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}") - logger.debug(f"FTP PWD failed: {e}") + logger.debug("FTP PWD failed: %s", e) # Try to list root directory try: @@ -313,10 +313,10 @@ class BambuFTPClient: self._ftp.retrlines("LIST", items.append) results["can_list_root"] = True 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}") - logger.debug(f"FTP LIST / failed: {e}") + logger.debug("FTP LIST / failed: %s", e) # Try to list /cache (should exist on all printers) try: @@ -324,16 +324,16 @@ class BambuFTPClient: items = [] self._ftp.retrlines("LIST", items.append) 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}") - logger.debug(f"FTP LIST /cache failed: {e}") + logger.debug("FTP LIST /cache failed: %s", e) # Try to get storage info try: 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}") return results @@ -351,7 +351,7 @@ class BambuFTPClient: try: 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) logger.debug("Running pre-upload storage diagnostics...") @@ -362,14 +362,14 @@ class BambuFTPClient: f"storage={diag['storage_info']}, errors={diag['errors']}" ) 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 # Use manual transfer instead of storbinary() for A1 compatibility # A1 printers have issues with storbinary's voidresp() hanging after transfer 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}") # Set explicit socket options for reliable transfer @@ -385,24 +385,24 @@ class BambuFTPClient: conn.sendall(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: progress_callback(uploaded, file_size) except OSError as e: - logger.error(f"FTP connection lost during upload: {e}") + logger.error("FTP connection lost during upload: %s", e) conn.close() raise conn.close() - logger.info(f"FTP upload complete: {remote_path}") + logger.info("FTP upload complete: %s", remote_path) return True except ftplib.error_perm as e: # Permanent FTP error (4xx/5xx response) 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": logger.error( "FTP 553 error - Could not create file. Possible causes: " @@ -414,8 +414,8 @@ class BambuFTPClient: elif error_code == "552": logger.error("FTP 552 error - Storage quota exceeded (SD card full?)") 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 def upload_bytes(self, data: bytes, remote_path: str) -> bool: @@ -437,13 +437,13 @@ class BambuFTPClient: conn.sendall(chunk) offset += len(chunk) 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() raise conn.close() return True - except Exception: + except (OSError, ftplib.error_reply): return False def delete_file(self, remote_path: str) -> bool: @@ -454,8 +454,8 @@ class BambuFTPClient: try: self._ftp.delete(remote_path) 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 def get_file_size(self, remote_path: str) -> int | None: @@ -465,7 +465,7 @@ class BambuFTPClient: try: return self._ftp.size(remote_path) - except Exception: + except (OSError, ftplib.error_reply): return 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: response = self._ftp.sendcmd("AVBL") - logger.debug(f"AVBL response: {response}") + logger.debug("AVBL response: %s", response) # Response format: "213 " if response.startswith("213"): parts = response.split() if len(parts) >= 2: 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: 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 # Calculate used space by listing root directories @@ -511,11 +511,11 @@ class BambuFTPClient: total_used += int(parts[4]) except ValueError: pass - except Exception: + except (OSError, ftplib.error_reply): pass result["used_bytes"] = total_used - except Exception: + except (OSError, ftplib.error_reply): pass return result if result else None @@ -587,7 +587,7 @@ async def download_file_async( return False 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 @@ -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 ) if client.connect(): - logger.info(f"FTP connected to {ip_address}") + logger.info("FTP connected to %s", ip_address) try: result = client.upload_file(local_path, remote_path, progress_callback) if result: @@ -667,7 +667,7 @@ async def upload_file_async( return result finally: client.disconnect() - logger.warning(f"FTP connection failed to {ip_address}") + logger.warning("FTP connection failed to %s", ip_address) return False try: @@ -694,7 +694,7 @@ async def upload_file_async( return False 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 @@ -726,7 +726,7 @@ async def list_files_async( try: return await asyncio.wait_for(loop.run_in_executor(None, _list), timeout=timeout) 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 [] @@ -856,21 +856,21 @@ async def with_ftp_retry( # Check for "falsy" success indicators if result not in (False, None, []): 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 # Operation returned failure indicator 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: 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 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) - 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: - logger.debug(f"Last error: {last_error}") + logger.debug("Last error: %s", last_error) return None diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index 318b1ac50..a3ce57e83 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -351,7 +351,7 @@ class BambuMQTTClient: ) return - logger.warning(f"[{self.serial_number}] MQTT disconnected: rc={rc}, flags={disconnect_flags}") + logger.warning("[%s] MQTT disconnected: rc=%s, flags=%s", self.serial_number, rc, disconnect_flags) self.state.connected = False if self.on_state_change: self.on_state_change(self.state) @@ -366,7 +366,7 @@ class BambuMQTTClient: # TEMP: Dump full payload once to find extruder state field if not hasattr(self, "_payload_dumped"): self._payload_dumped = True - logger.info(f"[{self.serial_number}] FULL MQTT PAYLOAD DUMP:\n{json.dumps(payload, indent=2)}") + logger.info("[%s] FULL MQTT PAYLOAD DUMP:\n%s", self.serial_number, json.dumps(payload, indent=2)) # Log message if logging is enabled if self._logging_enabled: self._message_log.append( @@ -389,12 +389,12 @@ class BambuMQTTClient: try: self._handle_ams_data(payload["ams"]) except Exception as e: - logger.error(f"[{self.serial_number}] Error handling AMS data: {e}") + logger.error("[%s] Error handling AMS data: %s", self.serial_number, e) # Handle xcam data (camera settings and AI detection) at top level if "xcam" in payload: xcam_data = payload["xcam"] - logger.info(f"[{self.serial_number}] Received xcam data at top level: {xcam_data}") + logger.info("[%s] Received xcam data at top level: %s", self.serial_number, xcam_data) self._parse_xcam_data(xcam_data) # Fire state change callback for top-level xcam (not nested in "print") if "print" not in payload and self.on_state_change: @@ -403,7 +403,7 @@ class BambuMQTTClient: # Handle system responses (accessories info, etc.) if "system" in payload: system_data = payload["system"] - logger.info(f"[{self.serial_number}] Received system data: {system_data}") + logger.info("[%s] Received system data: %s", self.serial_number, system_data) self._handle_system_response(system_data) # Handle info responses (firmware version info from get_version command) @@ -428,7 +428,7 @@ class BambuMQTTClient: # Check if xcam is nested inside print data if "xcam" in print_data: - logger.info(f"[{self.serial_number}] Found xcam inside print data: {print_data['xcam']}") + logger.info("[%s] Found xcam inside print data: %s", self.serial_number, print_data["xcam"]) self._parse_xcam_data(print_data["xcam"]) # Log when we see gcode_state changes @@ -443,7 +443,7 @@ class BambuMQTTClient: try: self._handle_ams_data(print_data["ams"]) except Exception as e: - logger.error(f"[{self.serial_number}] Error handling AMS data from print: {e}") + logger.error("[%s] Error handling AMS data from print: %s", self.serial_number, e) # Handle vt_tray (virtual tray / external spool) data if "vt_tray" in print_data: @@ -451,7 +451,7 @@ class BambuMQTTClient: self.state.raw_data["vt_tray"] = vt_tray # Log vt_tray to investigate per-extruder data for H2D if not hasattr(self, "_vt_tray_logged") or not self._vt_tray_logged: - logger.info(f"[{self.serial_number}] vt_tray data: {vt_tray}") + logger.info("[%s] vt_tray data: %s", self.serial_number, vt_tray) self._vt_tray_logged = True # Parse ams_status directly from print data (NOT from print.ams) @@ -480,7 +480,7 @@ class BambuMQTTClient: # Check for K-profile response (extrusion_cali) if "command" in print_data: - logger.debug(f"[{self.serial_number}] Received command response: {print_data.get('command')}") + logger.debug("[%s] Received command response: %s", self.serial_number, print_data.get("command")) if "command" in print_data and print_data.get("command") == "extrusion_cali_get": self._handle_kprofile_response(print_data) @@ -500,7 +500,7 @@ class BambuMQTTClient: # Log response for debugging - but DON'T use it to update nozzle data # because it returns stale values (e.g., 'stainless_steel' when the # actual nozzle is 'HH01' hardened steel high-flow) - logger.info(f"[{self.serial_number}] Accessories response (not used for nozzle data): {data}") + logger.info("[%s] Accessories response (not used for nozzle data): %s", self.serial_number, data) def _handle_version_info(self, data: dict): """Handle version info response from get_version command. @@ -529,7 +529,7 @@ class BambuMQTTClient: old_version = self.state.firmware_version self.state.firmware_version = version if old_version != version: - logger.info(f"[{self.serial_number}] Firmware version: {version}") + logger.info("[%s] Firmware version: %s", self.serial_number, version) # Trigger state change callback if self.on_state_change: self.on_state_change(self.state) @@ -559,7 +559,7 @@ class BambuMQTTClient: if elapsed > self._xcam_hold_time: # Hold timer expired - accept incoming and clear hold del self._xcam_hold_start[module_name] - logger.debug(f"[{self.serial_number}] Hold expired for {module_name}, accepting {incoming_value}") + logger.debug("[%s] Hold expired for %s, accepting %s", self.serial_number, module_name, incoming_value) return True # Within hold period - ignore incoming data @@ -570,7 +570,7 @@ class BambuMQTTClient: return False # Log all xcam fields for debugging - logger.debug(f"[{self.serial_number}] Parsing xcam data - all fields: {list(xcam_data.keys())}") + logger.debug("[%s] Parsing xcam data - all fields: %s", self.serial_number, list(xcam_data.keys())) # The cfg bitmask contains the ACTUAL detector states - the individual boolean # fields (spaghetti_detector, etc.) are often stale/cached. @@ -582,7 +582,7 @@ class BambuMQTTClient: # Sensitivity values: 0=low, 1=medium, 2=high if "cfg" in xcam_data: cfg = xcam_data["cfg"] - logger.debug(f"[{self.serial_number}] xcam cfg bitmask: {cfg} (binary: {bin(cfg)})") + logger.debug("[%s] xcam cfg bitmask: %s (binary: %s)", self.serial_number, cfg, bin(cfg)) def decode_detector(start_bit): """Decode a detector from cfg: returns (enabled, sensitivity_str)""" @@ -762,7 +762,7 @@ class BambuMQTTClient: # Log all AMS dict fields to debug tray_now for H2D dual-nozzle non_list_fields = {k: v for k, v in ams_data.items() if k != "ams"} if non_list_fields: - logger.debug(f"[{self.serial_number}] AMS dict fields: {non_list_fields}") + logger.debug("[%s] AMS dict fields: %s", self.serial_number, non_list_fields) # IMPORTANT: Parse ams_status FIRST before tray_now, so we have fresh status # when checking if we're in filament change mode for tray_now disambiguation @@ -899,7 +899,7 @@ class BambuMQTTClient: # Trust the printer's reported value. self.state.tray_now = parsed_tray_now - logger.debug(f"[{self.serial_number}] tray_now updated: {self.state.tray_now}") + logger.debug("[%s] tray_now updated: %s", self.serial_number, self.state.tray_now) # NOTE: ams_status is parsed BEFORE tray_now (see above) to ensure correct # state when checking filament change mode for H2D disambiguation @@ -907,12 +907,12 @@ class BambuMQTTClient: # P1S/P1P send partial updates without "ams" key - this is valid, not an error # We've already processed the status fields above, so just return if no ams list if ams_list is None: - logger.debug(f"[{self.serial_number}] AMS partial update (no tray data)") + logger.debug("[%s] AMS partial update (no tray data)", self.serial_number) return elif isinstance(ams_data, list): ams_list = ams_data else: - logger.warning(f"[{self.serial_number}] Unexpected AMS data format: {type(ams_data)}") + logger.warning("[%s] Unexpected AMS data format: %s", self.serial_number, type(ams_data)) return # Merge AMS data instead of replacing, to handle partial updates @@ -1010,13 +1010,13 @@ class BambuMQTTClient: tray["tray_info_idx"] = "" tray["remain"] = 0 except (ValueError, TypeError) as e: - logger.debug(f"[{self.serial_number}] Could not parse tray_exist_bits: {e}") + logger.debug("[%s] Could not parse tray_exist_bits: %s", self.serial_number, e) self.state.raw_data["ams"] = merged_ams # Update timestamp for RFID refresh detection (frontend can detect "new data arrived") self.state.last_ams_update = time.time() - logger.debug(f"[{self.serial_number}] Merged AMS data: {len(ams_list)} new units, {len(merged_ams)} total") + logger.debug("[%s] Merged AMS data: %s new units, %s total", self.serial_number, len(ams_list), len(merged_ams)) # Extract ams_extruder_map from each AMS unit's info field # According to OpenBambuAPI: info field bit 8 indicates which extruder (0=right, 1=left) @@ -1042,7 +1042,7 @@ class BambuMQTTClient: if ams_extruder_map: self.state.raw_data["ams_extruder_map"] = ams_extruder_map self.state.ams_extruder_map = ams_extruder_map # Also set on state for inference logic - logger.debug(f"[{self.serial_number}] ams_extruder_map: {ams_extruder_map}") + logger.debug("[%s] ams_extruder_map: %s", self.serial_number, ams_extruder_map) # Create a hash of relevant AMS data to detect changes ams_hash_data = [] @@ -1059,7 +1059,7 @@ class BambuMQTTClient: if ams_hash != self._previous_ams_hash: self._previous_ams_hash = ams_hash if self.on_ams_change: - logger.info(f"[{self.serial_number}] AMS data changed, triggering sync callback") + logger.info("[%s] AMS data changed, triggering sync callback", self.serial_number) self.on_ams_change(ams_list) def _update_state(self, data: dict): @@ -1123,7 +1123,7 @@ class BambuMQTTClient: if not hasattr(self, "_fan_fields_logged"): fan_fields = {k: v for k, v in data.items() if "fan" in k.lower()} if fan_fields: - logger.info(f"[{self.serial_number}] Fan fields in MQTT data: {fan_fields}") + logger.info("[%s] Fan fields in MQTT data: %s", self.serial_number, fan_fields) self._fan_fields_logged = True if "cooling_fan_speed" in data: @@ -1152,15 +1152,15 @@ class BambuMQTTClient: # Log all fields for debugging dual-nozzle temperature discovery (only once) if "bed_temper" in data and not hasattr(self, "_temp_fields_logged"): temp_fields = {k: v for k, v in data.items() if "temp" in k.lower() or "chamber" in k.lower()} - logger.info(f"[{self.serial_number}] Temperature-related fields: {temp_fields}") + logger.info("[%s] Temperature-related fields: %s", self.serial_number, temp_fields) # Log ALL keys in print data for H2D temperature discovery all_keys = sorted(data.keys()) - logger.info(f"[{self.serial_number}] ALL print data keys ({len(all_keys)}): {all_keys}") + logger.info("[%s] ALL print data keys (%s): %s", self.serial_number, len(all_keys), all_keys) self._temp_fields_logged = True # Log vir_slot data (once) - this may contain per-extruder slot mapping for H2D if "vir_slot" in data and not hasattr(self, "_vir_slot_logged"): - logger.info(f"[{self.serial_number}] vir_slot data: {data['vir_slot']}") + logger.info("[%s] vir_slot data: %s", self.serial_number, data["vir_slot"]) self._vir_slot_logged = True # Log nozzle hardware info fields (once) @@ -1170,7 +1170,7 @@ class BambuMQTTClient: if "nozzle" in k.lower() or "hw" in k.lower() or "extruder" in k.lower() or "upgrade" in k.lower() } if nozzle_fields and not hasattr(self, "_nozzle_fields_logged"): - logger.info(f"[{self.serial_number}] Nozzle/hardware fields in MQTT data: {nozzle_fields}") + logger.info("[%s] Nozzle/hardware fields in MQTT data: %s", self.serial_number, nozzle_fields) self._nozzle_fields_logged = True # Parse active extruder from device.extruder.state bit 8 # bit 8 = 0 → RIGHT extruder (active_extruder=0) @@ -1202,7 +1202,7 @@ class BambuMQTTClient: ) # Log 'cur' field if present (might indicate current/active extruder) if "cur" in ext_data: - logger.info(f"[{self.serial_number}] device.extruder.cur: {ext_data['cur']}") + logger.info("[%s] device.extruder.cur: %s", self.serial_number, ext_data["cur"]) if "bed_temper" in data: temps["bed"] = float(data["bed_temper"]) if "bed_target_temper" in data: @@ -1237,25 +1237,25 @@ class BambuMQTTClient: if -50 < val < 500: # Valid temp range temps["nozzle_2"] = val else: - logger.debug(f"[{self.serial_number}] nozzle_temper_2={val} out of range") + logger.debug("[%s] nozzle_temper_2=%s out of range", self.serial_number, val) elif "right_nozzle_temper" in data: val = float(data["right_nozzle_temper"]) if -50 < val < 500: # Valid temp range temps["nozzle_2"] = val else: - logger.debug(f"[{self.serial_number}] right_nozzle_temper={val} out of range") + logger.debug("[%s] right_nozzle_temper=%s out of range", self.serial_number, val) if "nozzle_target_temper_2" in data: val = float(data["nozzle_target_temper_2"]) if 0 <= val < 500: # Valid temp range temps["nozzle_2_target"] = val else: - logger.debug(f"[{self.serial_number}] nozzle_target_temper_2={val} out of range") + logger.debug("[%s] nozzle_target_temper_2=%s out of range", self.serial_number, val) elif "right_nozzle_target_temper" in data: val = float(data["right_nozzle_target_temper"]) if 0 <= val < 500: # Valid temp range temps["nozzle_2_target"] = val else: - logger.debug(f"[{self.serial_number}] right_nozzle_target_temper={val} out of range") + logger.debug("[%s] right_nozzle_target_temper=%s out of range", self.serial_number, val) # Also check for left nozzle as primary (some H2 models) if "left_nozzle_temper" in data and "nozzle" not in temps: temps["nozzle"] = float(data["left_nozzle_temper"]) @@ -1263,7 +1263,7 @@ class BambuMQTTClient: temps["nozzle_target"] = float(data["left_nozzle_target_temper"]) if "chamber_temper" in data: chamber_val = float(data["chamber_temper"]) - logger.debug(f"[{self.serial_number}] chamber_temper raw value: {chamber_val}") + logger.debug("[%s] chamber_temper raw value: %s", self.serial_number, chamber_val) # Check if we recently set the target locally (within 5 seconds) local_set_time = self.state.temperatures.get("_chamber_target_set_time", 0) respect_local = (time.time() - local_set_time) < 5.0 @@ -1275,9 +1275,9 @@ class BambuMQTTClient: temps["chamber"] = chamber_val if not respect_local: temps["chamber_target"] = 0.0 # Heater off means target = 0 - logger.debug(f"[{self.serial_number}] chamber_temper direct value: {chamber_val}°C (heater OFF)") + logger.debug("[%s] chamber_temper direct value: %s°C (heater OFF)", self.serial_number, chamber_val) else: - logger.debug(f"[{self.serial_number}] chamber_temper {chamber_val} out of direct range") + logger.debug("[%s] chamber_temper %s out of direct range", self.serial_number, chamber_val) # Try to decode if it looks like an encoded value if chamber_val > 500: mqtt_target = int(chamber_val) // 65536 @@ -1297,7 +1297,7 @@ class BambuMQTTClient: # Chamber target temperature (set by print file or display) if "mc_target_cham" in data: mc_target = float(data["mc_target_cham"]) - logger.debug(f"[{self.serial_number}] mc_target_cham raw value: {mc_target}") + logger.debug("[%s] mc_target_cham raw value: %s", self.serial_number, mc_target) # Filter out encoded/invalid values - valid chamber target is 0-60°C if 0 <= mc_target <= 60: temps["chamber_target"] = mc_target @@ -1324,7 +1324,7 @@ class BambuMQTTClient: # Valid direct temperature - heater is OFF temps["chamber"] = float(info_temp) temps["chamber_target"] = 0.0 # Direct value means heater off - logger.debug(f"[{self.serial_number}] info.temp direct: {info_temp}°C (heater OFF)") + logger.debug("[%s] info.temp direct: %s°C (heater OFF)", self.serial_number, info_temp) # H2D series: Dual extruder temps are in device.extruder.info array # Temperature values are encoded as fixed-point (value / 65536 = °C) if "device" in data and isinstance(data["device"], dict): @@ -1459,7 +1459,7 @@ class BambuMQTTClient: # Log ctc_info contents for debugging if ctc_info: - logger.debug(f"[{self.serial_number}] ctc_info keys: {list(ctc_info.keys())}") + logger.debug("[%s] ctc_info keys: %s", self.serial_number, list(ctc_info.keys())) # FIRST: Parse explicit ctc.info.target if available - this is the authoritative target # (what the slicer shows). This OVERRIDES any previously decoded target. @@ -1480,7 +1480,7 @@ class BambuMQTTClient: # Parse chamber temp from ctc.info.temp - may be encoded if "temp" in ctc_info and "chamber" not in temps: temp_val = ctc_info["temp"] - logger.debug(f"[{self.serial_number}] ctc_info.temp raw value: {temp_val}") + logger.debug("[%s] ctc_info.temp raw value: %s", self.serial_number, temp_val) if temp_val > 500: # Encoded value: decode target and current decoded_target = temp_val // 65536 @@ -1508,7 +1508,7 @@ class BambuMQTTClient: temps["chamber"] = float(temp_val) temps["chamber_heating"] = False except Exception as e: - logger.warning(f"[{self.serial_number}] Error parsing H2D temperatures: {e}") + logger.warning("[%s] Error parsing H2D temperatures: %s", self.serial_number, e) if temps: # Handle chamber_target: prefer explicit over decoded if "_chamber_decoded_target" in temps and "chamber_target" not in temps: @@ -1556,7 +1556,7 @@ class BambuMQTTClient: # Parse HMS (Health Management System) errors if "hms" in data: hms_list = data["hms"] - logger.info(f"[{self.serial_number}] HMS data received: {hms_list}") + logger.info("[%s] HMS data received: %s", self.serial_number, hms_list) self.state.hms_errors = [] if isinstance(hms_list, list): for hms in hms_list: @@ -1642,7 +1642,7 @@ class BambuMQTTClient: # Parse timelapse status (recording active during print) if "timelapse" in data: - logger.debug(f"[{self.serial_number}] timelapse field: {data['timelapse']}") + logger.debug("[%s] timelapse field: %s", self.serial_number, data["timelapse"]) self.state.timelapse = data["timelapse"] is True # Track if timelapse was ever active during this print if self.state.timelapse and self._was_running: @@ -1651,7 +1651,7 @@ class BambuMQTTClient: # Parse ipcam/live view status if "ipcam" in data: ipcam_data = data["ipcam"] - logger.debug(f"[{self.serial_number}] ipcam field: {ipcam_data}") + logger.debug("[%s] ipcam field: %s", self.serial_number, ipcam_data) if isinstance(ipcam_data, dict): # Check ipcam_record field for live view status self.state.ipcam = ipcam_data.get("ipcam_record") == "enable" @@ -1666,14 +1666,14 @@ class BambuMQTTClient: # Track if timelapse was ever active during this print if self.state.timelapse and self._was_running: self._timelapse_during_print = True - logger.info(f"[{self.serial_number}] Timelapse detected during print (from ipcam)") + logger.info("[%s] Timelapse detected during print (from ipcam)", self.serial_number) else: self.state.ipcam = ipcam_data is True # Parse WiFi signal strength (dBm) if "wifi_signal" in data: wifi_signal = data["wifi_signal"] - logger.info(f"[{self.serial_number}] wifi_signal received: {wifi_signal}") + logger.info("[%s] wifi_signal received: %s", self.serial_number, wifi_signal) if isinstance(wifi_signal, (int, float)): self.state.wifi_signal = int(wifi_signal) elif isinstance(wifi_signal, str): @@ -1687,7 +1687,7 @@ class BambuMQTTClient: if "spd_lvl" in data: new_speed = data["spd_lvl"] if new_speed != self.state.speed_level: - logger.info(f"[{self.serial_number}] speed_level changed: {self.state.speed_level} -> {new_speed}") + logger.info("[%s] speed_level changed: %s -> %s", self.serial_number, self.state.speed_level, new_speed) self.state.speed_level = new_speed # Parse skipped objects from printer status (s_obj field) @@ -1698,13 +1698,13 @@ class BambuMQTTClient: # Update skipped objects from printer's list new_skipped = [int(oid) for oid in s_obj if isinstance(oid, (int, str))] if new_skipped != self.state.skipped_objects: - logger.info(f"[{self.serial_number}] skipped_objects updated from printer: {new_skipped}") + logger.info("[%s] skipped_objects updated from printer: %s", self.serial_number, new_skipped) self.state.skipped_objects = new_skipped # Parse chamber light status from lights_report if "lights_report" in data: lights = data["lights_report"] - logger.debug(f"[{self.serial_number}] lights_report: {lights}") + logger.debug("[%s] lights_report: %s", self.serial_number, lights) if isinstance(lights, list): for light in lights: if isinstance(light, dict) and light.get("node") == "chamber_light": @@ -1792,11 +1792,11 @@ class BambuMQTTClient: # Track RUNNING state for more robust completion detection if self.state.state == "RUNNING" and current_file: if not self._was_running: - logger.info(f"[{self.serial_number}] Now tracking RUNNING state for {current_file}") + logger.info("[%s] Now tracking RUNNING state for %s", self.serial_number, current_file) # Check if timelapse was enabled in the same message (xcam parsed before this) if self.state.timelapse: self._timelapse_during_print = True - logger.info(f"[{self.serial_number}] Timelapse detected when entering RUNNING state") + logger.info("[%s] Timelapse detected when entering RUNNING state", self.serial_number) self._was_running = True self._completion_triggered = False @@ -1814,7 +1814,7 @@ class BambuMQTTClient: # We preserve that value instead of blindly resetting to False. if self.state.timelapse: self._timelapse_during_print = True - logger.info(f"[{self.serial_number}] Timelapse detected at print start") + logger.info("[%s] Timelapse detected at print start", self.serial_number) else: self._timelapse_during_print = False @@ -1915,7 +1915,7 @@ class BambuMQTTClient: "command": "get_version", } } - logger.debug(f"[{self.serial_number}] Requesting firmware version info") + logger.debug("[%s] Requesting firmware version info", self.serial_number) self._client.publish(self.topic_publish, json.dumps(message), qos=1) def request_status_update(self) -> bool: @@ -1928,9 +1928,9 @@ class BambuMQTTClient: True if the request was sent, False if not connected. """ if not self._client or not self.state.connected: - logger.warning(f"[{self.serial_number}] request_status_update: not connected") + logger.warning("[%s] request_status_update: not connected", self.serial_number) return False - logger.info(f"[{self.serial_number}] Requesting status update (pushall)") + logger.info("[%s] Requesting status update (pushall)", self.serial_number) self._request_push_all() # Note: get_accessories returns stale nozzle data on H2D. # The correct nozzle data comes from push_status response. @@ -1947,7 +1947,7 @@ class BambuMQTTClient: "accessory_type": "none", } } - logger.debug(f"[{self.serial_number}] Requesting accessories info") + logger.debug("[%s] Requesting accessories info", self.serial_number) self._client.publish(self.topic_publish, json.dumps(message), qos=1) def _prime_kprofile_request(self): @@ -1966,7 +1966,7 @@ class BambuMQTTClient: "sequence_id": str(self._sequence_id), } } - logger.debug(f"[{self.serial_number}] Sending K-profile priming request") + logger.debug("[%s] Sending K-profile priming request", self.serial_number) self._client.publish(self.topic_publish, json.dumps(command), qos=1) def connect(self, loop: asyncio.AbstractEventLoop | None = None): @@ -2082,26 +2082,26 @@ class BambuMQTTClient: } if is_h2d: - logger.info(f"[{self.serial_number}] H2D series detected: using integer format for boolean fields") + logger.info("[%s] H2D series detected: using integer format for boolean fields", self.serial_number) # P2S-specific parameter adjustments # P2S printer doesn't support vibration calibration like X1/P1 series if self.model and self.model.upper().strip() in ("P2S", "N7"): command["print"]["vibration_cali"] = False - logger.info(f"[{self.serial_number}] P2S detected: disabling vibration_cali") + logger.info("[%s] P2S detected: disabling vibration_cali", self.serial_number) # Add AMS mapping if provided if ams_mapping is not None: command["print"]["ams_mapping"] = ams_mapping command["print"]["ams_mapping2"] = ams_mapping2 - logger.info(f"[{self.serial_number}] Sending print command: {json.dumps(command)}") + logger.info("[%s] Sending print command: %s", self.serial_number, json.dumps(command)) self._client.publish(self.topic_publish, json.dumps(command), qos=1) return True else: # Log why we couldn't send the command if not self._client: - logger.error(f"[{self.serial_number}] Cannot start print: MQTT client not initialized") + logger.error("[%s] Cannot start print: MQTT client not initialized", self.serial_number) elif not self.state.connected: logger.error( f"[{self.serial_number}] Cannot start print: Printer not connected (client exists but disconnected). " @@ -2114,7 +2114,7 @@ class BambuMQTTClient: if self._client and self.state.connected: command = {"print": {"command": "stop", "sequence_id": "0"}} self._client.publish(self.topic_publish, json.dumps(command), qos=1) - logger.info(f"[{self.serial_number}] Sent stop print command") + logger.info("[%s] Sent stop print command", self.serial_number) return True return False @@ -2167,8 +2167,10 @@ class BambuMQTTClient: command_json = json.dumps(command) self._client.publish(self.topic_publish, command_json, qos=1) - logger.info(f"[{self.serial_number}] Set xcam option: {module_name}={enabled}, sensitivity={sensitivity}") - logger.debug(f"[{self.serial_number}] MQTT command sent: {command_json}") + logger.info( + "[%s] Set xcam option: %s=%s, sensitivity=%s", self.serial_number, module_name, enabled, sensitivity + ) + logger.debug("[%s] MQTT command sent: %s", self.serial_number, command_json) # OrcaSlicer pattern: Set hold timer to ignore incoming data for 3 seconds # This prevents stale MQTT data from immediately overwriting our change @@ -2244,7 +2246,7 @@ class BambuMQTTClient: command_json = json.dumps(command) self._client.publish(self.topic_publish, command_json, qos=1) - logger.info(f"[{self.serial_number}] Set print option: {option_name}={enabled}") + logger.info("[%s] Set print option: %s=%s", self.serial_number, option_name, enabled) # Set hold timer hold_key = f"print_option_{option_name}" @@ -2300,7 +2302,7 @@ class BambuMQTTClient: option |= 1 << 5 if option == 0: - logger.warning(f"[{self.serial_number}] No calibration options selected") + logger.warning("[%s] No calibration options selected", self.serial_number) return False self._sequence_id += 1 @@ -2439,7 +2441,7 @@ class BambuMQTTClient: ) ) except (ValueError, TypeError) as e: - logger.warning(f"Failed to parse K-profile: {e}") + logger.warning("Failed to parse K-profile: %s", e) self.state.kprofiles = profiles self._kprofile_response_data = profiles @@ -2447,7 +2449,7 @@ class BambuMQTTClient: # Signal that we received the response (only if we were waiting for one) # Use thread-safe method since MQTT callbacks run in a different thread if self._pending_kprofile_response: - logger.info(f"[{self.serial_number}] Got {len(profiles)} K-profiles for nozzle={response_nozzle}") + logger.info("[%s] Got %s K-profiles for nozzle=%s", self.serial_number, len(profiles), response_nozzle) if self._loop and self._loop.is_running(): self._loop.call_soon_threadsafe(self._pending_kprofile_response.set) else: @@ -2471,14 +2473,14 @@ class BambuMQTTClient: List of KProfile objects """ if not self._client or not self.state.connected: - logger.warning(f"[{self.serial_number}] Cannot get K-profiles: not connected") + logger.warning("[%s] Cannot get K-profiles: not connected", self.serial_number) return [] # Capture current event loop for thread-safe callback try: self._loop = asyncio.get_running_loop() except RuntimeError: - logger.warning(f"[{self.serial_number}] No running event loop") + logger.warning("[%s] No running event loop", self.serial_number) return [] for attempt in range(max_retries): @@ -2501,7 +2503,7 @@ class BambuMQTTClient: logger.info( f"[{self.serial_number}] Requesting K-profiles for nozzle_diameter={nozzle_diameter} (attempt {attempt + 1}/{max_retries})" ) - logger.debug(f"[{self.serial_number}] K-profile request JSON: {json.dumps(command)}") + logger.debug("[%s] K-profile request JSON: %s", self.serial_number, json.dumps(command)) self._client.publish(self.topic_publish, json.dumps(command), qos=1) # Wait for response (response handler already filters by nozzle_diameter) @@ -2523,7 +2525,7 @@ class BambuMQTTClient: self._pending_kprofile_response = None self._expected_kprofile_nozzle = None - logger.error(f"[{self.serial_number}] Failed to get K-profiles after {max_retries} attempts") + logger.error("[%s] Failed to get K-profiles after %s attempts", self.serial_number, max_retries) return [] def set_kprofile( @@ -2555,7 +2557,7 @@ class BambuMQTTClient: True if command was sent, False otherwise """ if not self._client or not self.state.connected: - logger.warning(f"[{self.serial_number}] Cannot set K-profile: not connected") + logger.warning("[%s] Cannot set K-profile: not connected", self.serial_number) return False self._sequence_id += 1 @@ -2602,7 +2604,7 @@ class BambuMQTTClient: logger.info( f"[{self.serial_number}] Setting K-profile: {name} = {k_value} (cali_idx={effective_cali_idx}, new={slot_id == 0})" ) - logger.info(f"[{self.serial_number}] K-profile SET command: {command_json}") + logger.info("[%s] K-profile SET command: %s", self.serial_number, command_json) self._client.publish(self.topic_publish, command_json, qos=1) return True @@ -2622,7 +2624,7 @@ class BambuMQTTClient: True if command was sent, False otherwise """ if not self._client or not self.state.connected: - logger.warning(f"[{self.serial_number}] Cannot set K-profiles batch: not connected") + logger.warning("[%s] Cannot set K-profiles batch: not connected", self.serial_number) return False import random @@ -2669,8 +2671,8 @@ class BambuMQTTClient: } command_json = json.dumps(command) - logger.info(f"[{self.serial_number}] Setting {len(filament_entries)} K-profiles in batch") - logger.info(f"[{self.serial_number}] K-profile SET batch command: {command_json}") + logger.info("[%s] Setting %s K-profiles in batch", self.serial_number, len(filament_entries)) + logger.info("[%s] K-profile SET batch command: %s", self.serial_number, command_json) self._client.publish(self.topic_publish, command_json, qos=1) return True @@ -2697,7 +2699,7 @@ class BambuMQTTClient: True if command was sent, False otherwise """ if not self._client or not self.state.connected: - logger.warning(f"[{self.serial_number}] Cannot delete K-profile: not connected") + logger.warning("[%s] Cannot delete K-profile: not connected", self.serial_number) return False self._sequence_id += 1 @@ -2739,7 +2741,7 @@ class BambuMQTTClient: logger.info( f"[{self.serial_number}] Deleting K-profile: cali_idx={cali_idx}, filament={filament_id}, setting_id={setting_id}, dual={is_dual_nozzle}" ) - logger.info(f"[{self.serial_number}] K-profile DELETE command: {command_json}") + logger.info("[%s] K-profile DELETE command: %s", self.serial_number, command_json) # Use QoS 1 for reliable delivery (at least once) self._client.publish(self.topic_publish, command_json, qos=1) return True @@ -2751,23 +2753,23 @@ class BambuMQTTClient: def pause_print(self) -> bool: """Pause the current print job.""" if not self._client or not self.state.connected: - logger.warning(f"[{self.serial_number}] Cannot pause print: not connected") + logger.warning("[%s] Cannot pause print: not connected", self.serial_number) return False command = {"print": {"command": "pause", "sequence_id": "0"}} self._client.publish(self.topic_publish, json.dumps(command), qos=1) - logger.info(f"[{self.serial_number}] Sent pause print command") + logger.info("[%s] Sent pause print command", self.serial_number) return True def resume_print(self) -> bool: """Resume a paused print job.""" if not self._client or not self.state.connected: - logger.warning(f"[{self.serial_number}] Cannot resume print: not connected") + logger.warning("[%s] Cannot resume print: not connected", self.serial_number) return False command = {"print": {"command": "resume", "sequence_id": "0"}} self._client.publish(self.topic_publish, json.dumps(command), qos=1) - logger.info(f"[{self.serial_number}] Sent resume print command") + logger.info("[%s] Sent resume print command", self.serial_number) return True def skip_objects(self, object_ids: list[int]) -> bool: @@ -2783,7 +2785,7 @@ class BambuMQTTClient: True if command was sent, False otherwise """ if not self._client or not self.state.connected: - logger.warning(f"[{self.serial_number}] Cannot skip objects: not connected") + logger.warning("[%s] Cannot skip objects: not connected", self.serial_number) return False if self.state.state != "RUNNING" and self.state.state != "PAUSE": @@ -2793,20 +2795,20 @@ class BambuMQTTClient: return False if not object_ids: - logger.warning(f"[{self.serial_number}] Cannot skip objects: no object IDs provided") + logger.warning("[%s] Cannot skip objects: no object IDs provided", self.serial_number) return False # Validate all IDs are integers try: obj_list = [int(oid) for oid in object_ids] except (ValueError, TypeError) as e: - logger.warning(f"[{self.serial_number}] Invalid object IDs: {e}") + logger.warning("[%s] Invalid object IDs: %s", self.serial_number, e) return False self._sequence_id += 1 command = {"print": {"sequence_id": str(self._sequence_id), "command": "skip_objects", "obj_list": obj_list}} self._client.publish(self.topic_publish, json.dumps(command), qos=1) - logger.info(f"[{self.serial_number}] Sent skip_objects command: {obj_list}") + logger.info("[%s] Sent skip_objects command: %s", self.serial_number, obj_list) # Track skipped objects in state for oid in obj_list: @@ -2827,14 +2829,14 @@ class BambuMQTTClient: True if command was sent, False otherwise """ if not self._client or not self.state.connected: - logger.warning(f"[{self.serial_number}] Cannot send G-code: not connected") + logger.warning("[%s] Cannot send G-code: not connected", self.serial_number) return False self._sequence_id += 1 command = {"print": {"command": "gcode_line", "param": gcode, "sequence_id": str(self._sequence_id)}} # Use QoS 1 for reliable delivery (at least once) self._client.publish(self.topic_publish, json.dumps(command), qos=1) - logger.debug(f"[{self.serial_number}] Sent G-code: {gcode[:50]}...") + logger.debug("[%s] Sent G-code: %s...", self.serial_number, gcode[:50]) return True def set_bed_temperature(self, target: int) -> bool: @@ -2866,7 +2868,7 @@ class BambuMQTTClient: if result and nozzle == 1: self.state.temperatures["nozzle_target"] = float(target) self.state.temperatures["_nozzle_target_set_time"] = time.time() - logger.info(f"[{self.serial_number}] Tracking LEFT nozzle target locally: {target}°C") + logger.info("[%s] Tracking LEFT nozzle target locally: %s°C", self.serial_number, target) return result def set_chamber_temperature(self, target: int) -> bool: @@ -2902,16 +2904,16 @@ class BambuMQTTClient: True if command was sent, False otherwise """ if not self._client or not self.state.connected: - logger.warning(f"[{self.serial_number}] Cannot set print speed: not connected") + logger.warning("[%s] Cannot set print speed: not connected", self.serial_number) return False if mode not in (1, 2, 3, 4): - logger.warning(f"[{self.serial_number}] Invalid speed mode: {mode}") + logger.warning("[%s] Invalid speed mode: %s", self.serial_number, mode) return False command = {"print": {"command": "print_speed", "param": str(mode), "sequence_id": "0"}} self._client.publish(self.topic_publish, json.dumps(command), qos=1) - logger.info(f"[{self.serial_number}] Set print speed mode to {mode}") + logger.info("[%s] Set print speed mode to %s", self.serial_number, mode) return True def set_fan_speed(self, fan: int, speed: int) -> bool: @@ -2925,7 +2927,7 @@ class BambuMQTTClient: True if command was sent, False otherwise """ if fan not in (1, 2, 3): - logger.warning(f"[{self.serial_number}] Invalid fan index: {fan}") + logger.warning("[%s] Invalid fan index: %s", self.serial_number, fan) return False speed = max(0, min(255, speed)) # Clamp to 0-255 @@ -2956,7 +2958,7 @@ class BambuMQTTClient: True if command was sent, False otherwise """ if not self._client or not self.state.connected: - logger.warning(f"[{self.serial_number}] Cannot set airduct mode: not connected") + logger.warning("[%s] Cannot set airduct mode: not connected", self.serial_number) return False self._sequence_id += 1 @@ -2966,7 +2968,9 @@ class BambuMQTTClient: } # Use QoS 1 for reliable delivery self._client.publish(self.topic_publish, json.dumps(command), qos=1) - logger.info(f"[{self.serial_number}] Set airduct mode to {mode} (modeId={mode_id}, seq={self._sequence_id})") + logger.info( + "[%s] Set airduct mode to %s (modeId=%s, seq=%s)", self.serial_number, mode, mode_id, self._sequence_id + ) return True def set_chamber_light(self, on: bool) -> bool: @@ -2979,7 +2983,7 @@ class BambuMQTTClient: True if command was sent, False otherwise """ if not self._client or not self.state.connected: - logger.warning(f"[{self.serial_number}] Cannot set chamber light: not connected") + logger.warning("[%s] Cannot set chamber light: not connected", self.serial_number) return False mode = "on" if on else "off" @@ -2999,7 +3003,7 @@ class BambuMQTTClient: } } self._client.publish(self.topic_publish, json.dumps(command), qos=1) - logger.info(f"[{self.serial_number}] Set chamber lights {'on' if on else 'off'} (seq={self._sequence_id})") + logger.info("[%s] Set chamber lights %s (seq=%s)", self.serial_number, "on" if on else "off", self._sequence_id) return True def select_extruder(self, extruder: int) -> bool: @@ -3012,11 +3016,11 @@ class BambuMQTTClient: True if command was sent, False otherwise """ if extruder not in (0, 1): - logger.warning(f"[{self.serial_number}] Invalid extruder: {extruder}") + logger.warning("[%s] Invalid extruder: %s", self.serial_number, extruder) return False if not self._client or not self.state.connected: - logger.warning(f"[{self.serial_number}] Cannot switch extruder: not connected") + logger.warning("[%s] Cannot switch extruder: not connected", self.serial_number) return False # H2D extruder switching via select_extruder command @@ -3028,7 +3032,9 @@ class BambuMQTTClient: "print": {"command": "select_extruder", "extruder_index": extruder, "sequence_id": str(self._sequence_id)} } self._client.publish(self.topic_publish, json.dumps(command), qos=1) - logger.info(f"[{self.serial_number}] Sent select_extruder command: extruder_index={extruder} (0=right, 1=left)") + logger.info( + "[%s] Sent select_extruder command: extruder_index=%s (0=right, 1=left)", self.serial_number, extruder + ) return True def home_axes(self, axes: str = "XYZ") -> bool: @@ -3057,7 +3063,7 @@ class BambuMQTTClient: """ axis = axis.upper() if axis not in ("X", "Y", "Z"): - logger.warning(f"[{self.serial_number}] Invalid axis: {axis}") + logger.warning("[%s] Invalid axis: %s", self.serial_number, axis) return False # G91 = relative mode, G0 = rapid move, G90 = back to absolute @@ -3094,7 +3100,7 @@ class BambuMQTTClient: True if command was sent, False otherwise """ if not self._client or not self.state.connected: - logger.warning(f"[{self.serial_number}] Cannot load filament: not connected") + logger.warning("[%s] Cannot load filament: not connected", self.serial_number) return False # Calculate ams_id and slot_id for logging @@ -3122,15 +3128,15 @@ class BambuMQTTClient: } command_json = json.dumps(command) - logger.info(f"[{self.serial_number}] Publishing ams_change_filament command: {command_json}") + logger.info("[%s] Publishing ams_change_filament command: %s", self.serial_number, command_json) self._client.publish(self.topic_publish, command_json, qos=1) - logger.info(f"[{self.serial_number}] Loading filament from tray {tray_id} (AMS {ams_id} slot {slot_id})") + logger.info("[%s] Loading filament from tray %s (AMS %s slot %s)", self.serial_number, tray_id, ams_id, slot_id) # Track this load request for H2D dual-nozzle disambiguation # H2D reports only slot number (0-3) in tray_now, so we use our tracked value self._last_load_tray_id = tray_id self.state.pending_tray_target = tray_id - logger.info(f"[{self.serial_number}] Set pending_tray_target={tray_id} for H2D disambiguation") + logger.info("[%s] Set pending_tray_target=%s for H2D disambiguation", self.serial_number, tray_id) return True @@ -3141,12 +3147,12 @@ class BambuMQTTClient: True if command was sent, False otherwise """ if not self._client or not self.state.connected: - logger.warning(f"[{self.serial_number}] Cannot unload filament: not connected") + logger.warning("[%s] Cannot unload filament: not connected", self.serial_number) return False # Get the currently loaded tray info tray_now = self.state.tray_now - logger.info(f"[{self.serial_number}] Unload requested, tray_now={tray_now}") + logger.info("[%s] Unload requested, tray_now=%s", self.serial_number, tray_now) # Determine source ams_id for the unload command if tray_now == 255 or tray_now == 254: @@ -3177,14 +3183,14 @@ class BambuMQTTClient: } command_json = json.dumps(command) - logger.info(f"[{self.serial_number}] Publishing ams_change_filament (unload) command: {command_json}") + logger.info("[%s] Publishing ams_change_filament (unload) command: %s", self.serial_number, command_json) self._client.publish(self.topic_publish, command_json, qos=1) - logger.info(f"[{self.serial_number}] Unloading filament (tray_now was {tray_now})") + logger.info("[%s] Unloading filament (tray_now was %s)", self.serial_number, tray_now) # Clear tracked load request since we're unloading self._last_load_tray_id = None self.state.pending_tray_target = None - logger.info(f"[{self.serial_number}] Cleared pending_tray_target (unload)") + logger.info("[%s] Cleared pending_tray_target (unload)", self.serial_number) return True @@ -3198,16 +3204,16 @@ class BambuMQTTClient: True if command was sent, False otherwise """ if not self._client or not self.state.connected: - logger.warning(f"[{self.serial_number}] Cannot control AMS: not connected") + logger.warning("[%s] Cannot control AMS: not connected", self.serial_number) return False if action not in ("resume", "reset", "pause"): - logger.warning(f"[{self.serial_number}] Invalid AMS action: {action}") + logger.warning("[%s] Invalid AMS action: %s", self.serial_number, action) return False command = {"print": {"command": "ams_control", "param": action, "sequence_id": "0"}} self._client.publish(self.topic_publish, json.dumps(command), qos=1) - logger.info(f"[{self.serial_number}] AMS control: {action}") + logger.info("[%s] AMS control: %s", self.serial_number, action) return True def ams_refresh_tray(self, ams_id: int, tray_id: int) -> tuple[bool, str]: @@ -3221,7 +3227,7 @@ class BambuMQTTClient: Tuple of (success, message) """ if not self._client or not self.state.connected: - logger.warning(f"[{self.serial_number}] Cannot refresh AMS tray: not connected") + logger.warning("[%s] Cannot refresh AMS tray: not connected", self.serial_number) return False, "Printer not connected" # Check if filament is currently loaded (tray_now != 255) @@ -3237,14 +3243,14 @@ class BambuMQTTClient: loaded_tray = f"AMS {loaded_ams + 1} slot {loaded_slot + 1}" else: loaded_tray = f"tray {tray_now}" - logger.warning(f"[{self.serial_number}] Cannot refresh AMS tray: filament loaded from {loaded_tray}") + logger.warning("[%s] Cannot refresh AMS tray: filament loaded from %s", self.serial_number, loaded_tray) return False, f"Please unload filament first. Currently loaded: {loaded_tray}" # Use ams_get_rfid command to trigger RFID re-read # This command is used by Bambu Studio to re-read the RFID tag command = {"print": {"command": "ams_get_rfid", "ams_id": ams_id, "slot_id": tray_id, "sequence_id": "0"}} self._client.publish(self.topic_publish, json.dumps(command), qos=1) - logger.info(f"[{self.serial_number}] Triggering RFID re-read: AMS {ams_id}, slot {tray_id}") + logger.info("[%s] Triggering RFID re-read: AMS %s, slot %s", self.serial_number, ams_id, tray_id) return True, f"Refreshing AMS {ams_id} tray {tray_id}" def ams_set_filament_setting( @@ -3278,7 +3284,7 @@ class BambuMQTTClient: True if command was sent, False otherwise """ if not self._client or not self.state.connected: - logger.warning(f"[{self.serial_number}] Cannot set AMS filament setting: not connected") + logger.warning("[%s] Cannot set AMS filament setting: not connected", self.serial_number) return False # Calculate slot_id based on AMS type @@ -3312,7 +3318,7 @@ class BambuMQTTClient: logger.info( f"[{self.serial_number}] Publishing ams_filament_setting: AMS {ams_id}, tray {tray_id}, tray_info_idx={tray_info_idx}, setting_id={setting_id}" ) - logger.debug(f"[{self.serial_number}] ams_filament_setting command: {command_json}") + logger.debug("[%s] ams_filament_setting command: %s", self.serial_number, command_json) self._client.publish(self.topic_publish, command_json, qos=1) return True @@ -3327,7 +3333,7 @@ class BambuMQTTClient: True if command was sent, False otherwise """ if not self._client or not self.state.connected: - logger.warning(f"[{self.serial_number}] Cannot reset AMS slot: not connected") + logger.warning("[%s] Cannot reset AMS slot: not connected", self.serial_number) return False # Calculate slot_id based on AMS type @@ -3353,8 +3359,8 @@ class BambuMQTTClient: } command_json = json.dumps(command) - logger.info(f"[{self.serial_number}] Resetting AMS slot: AMS {ams_id}, tray {tray_id}") - logger.debug(f"[{self.serial_number}] reset_ams_slot command: {command_json}") + logger.info("[%s] Resetting AMS slot: AMS %s, tray %s", self.serial_number, ams_id, tray_id) + logger.debug("[%s] reset_ams_slot command: %s", self.serial_number, command_json) self._client.publish(self.topic_publish, command_json, qos=1) return True @@ -3384,7 +3390,7 @@ class BambuMQTTClient: True if command was sent, False otherwise """ if not self._client or not self.state.connected: - logger.warning(f"[{self.serial_number}] Cannot set calibration: not connected") + logger.warning("[%s] Cannot set calibration: not connected", self.serial_number) return False # Calculate slot_id based on AMS type @@ -3417,7 +3423,7 @@ class BambuMQTTClient: logger.info( f"[{self.serial_number}] Publishing extrusion_cali_sel: AMS {ams_id}, tray {tray_id}, cali_idx={cali_idx}, setting_id={setting_id}" ) - logger.debug(f"[{self.serial_number}] extrusion_cali_sel command: {command_json}") + logger.debug("[%s] extrusion_cali_sel command: %s", self.serial_number, command_json) self._client.publish(self.topic_publish, command_json, qos=1) return True @@ -3449,7 +3455,7 @@ class BambuMQTTClient: True if command was sent, False otherwise """ if not self._client or not self.state.connected: - logger.warning(f"[{self.serial_number}] Cannot set K value: not connected") + logger.warning("[%s] Cannot set K value: not connected", self.serial_number) return False command = { @@ -3467,8 +3473,8 @@ class BambuMQTTClient: } command_json = json.dumps(command) - logger.info(f"[{self.serial_number}] Publishing extrusion_cali_set: tray {tray_id}, k_value={k_value}") - logger.debug(f"[{self.serial_number}] extrusion_cali_set command: {command_json}") + logger.info("[%s] Publishing extrusion_cali_set: tray %s, k_value=%s", self.serial_number, tray_id, k_value) + logger.debug("[%s] extrusion_cali_set command: %s", self.serial_number, command_json) self._client.publish(self.topic_publish, command_json, qos=1) return True @@ -3482,7 +3488,7 @@ class BambuMQTTClient: True if command was sent, False otherwise """ if not self._client or not self.state.connected: - logger.warning(f"[{self.serial_number}] Cannot set timelapse: not connected") + logger.warning("[%s] Cannot set timelapse: not connected", self.serial_number) return False command = {"pushing": {"command": "pushall", "sequence_id": "0"}} @@ -3493,7 +3499,7 @@ class BambuMQTTClient: self._client.publish(self.topic_publish, json.dumps(timelapse_cmd), qos=1) # Request status update self._client.publish(self.topic_publish, json.dumps(command), qos=1) - logger.info(f"[{self.serial_number}] Set timelapse {'enabled' if enable else 'disabled'}") + logger.info("[%s] Set timelapse %s", self.serial_number, "enabled" if enable else "disabled") return True def set_liveview(self, enable: bool) -> bool: @@ -3506,7 +3512,7 @@ class BambuMQTTClient: True if command was sent, False otherwise """ if not self._client or not self.state.connected: - logger.warning(f"[{self.serial_number}] Cannot set liveview: not connected") + logger.warning("[%s] Cannot set liveview: not connected", self.serial_number) return False command = { @@ -3516,5 +3522,5 @@ class BambuMQTTClient: # Request status update pushall = {"pushing": {"command": "pushall", "sequence_id": "0"}} self._client.publish(self.topic_publish, json.dumps(pushall), qos=1) - logger.info(f"[{self.serial_number}] Set liveview {'enabled' if enable else 'disabled'}") + logger.info("[%s] Set liveview %s", self.serial_number, "enabled" if enable else "disabled") return True diff --git a/backend/app/services/camera.py b/backend/app/services/camera.py index 5c4f4cc7e..904e2a8e1 100644 --- a/backend/app/services/camera.py +++ b/backend/app/services/camera.py @@ -54,7 +54,7 @@ def get_ffmpeg_path() -> str | None: _ffmpeg_path = ffmpeg_path if ffmpeg_path: - logger.info(f"Found ffmpeg at: {ffmpeg_path}") + logger.info("Found ffmpeg at: %s", ffmpeg_path) else: logger.warning("ffmpeg not found in PATH or common locations") @@ -193,7 +193,7 @@ async def read_chamber_image_frame( payload_size = struct.unpack(" 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 # Read the JPEG data @@ -210,24 +210,24 @@ async def read_chamber_image_frame( if not jpeg_data.endswith(JPEG_END): 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 finally: writer.close() try: await writer.wait_closed() - except Exception: + except OSError: pass 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 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 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 @@ -254,11 +254,11 @@ async def generate_chamber_image_stream( writer.write(auth_payload) 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 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 @@ -272,7 +272,7 @@ async def read_next_chamber_frame(reader: asyncio.StreamReader, timeout: float = payload_size = struct.unpack(" 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 # 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") return None except Exception as e: - logger.error(f"Chamber image: error reading frame: {e}") + logger.error("Chamber image: error reading frame: %s", e) return None @@ -323,10 +323,10 @@ async def capture_camera_frame( try: with open(output_path, "wb") as f: f.write(jpeg_data) - logger.info(f"Saved camera frame to: {output_path}") + logger.info("Saved camera frame to: %s", output_path) 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 @@ -353,7 +353,7 @@ async def capture_camera_frame_bytes( """ # Chamber image models: A1/P1 - returns bytes directly 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)) # 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: process = await asyncio.create_subprocess_exec( @@ -398,22 +398,22 @@ async def capture_camera_frame_bytes( except TimeoutError: process.kill() 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 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 else: 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 except FileNotFoundError: logger.error("ffmpeg not found for camera frame capture") return None except Exception as e: - logger.exception(f"Camera frame bytes capture failed: {e}") + logger.exception("Camera frame bytes capture failed: %s", e) return None @@ -454,10 +454,10 @@ async def capture_finish_photo( ) if success: - logger.info(f"Finish photo saved: {filename}") + logger.info("Finish photo saved: %s", filename) return filename 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 diff --git a/backend/app/services/discovery.py b/backend/app/services/discovery.py index eee601461..80edaeceb 100644 --- a/backend/app/services/discovery.py +++ b/backend/app/services/discovery.py @@ -155,13 +155,13 @@ class PrinterDiscoveryService: # Enable broadcast 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 try: 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() last_send = start_time @@ -171,13 +171,13 @@ class PrinterDiscoveryService: try: data, addr = sock.recvfrom(4096) 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]) except BlockingIOError: # No data available, that's fine 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 now = asyncio.get_event_loop().time() @@ -185,27 +185,27 @@ class PrinterDiscoveryService: try: sock.sendto(SSDP_MSEARCH.encode(), (SSDP_ADDR, SSDP_PORT)) 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) - logger.info(f"Discovery complete. Found {len(self._discovered)} printers.") + logger.info("Discovery complete. Found %s printers.", len(self._discovered)) except OSError as e: 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) else: - logger.error(f"Discovery error: {e}") + logger.error("Discovery error: %s", e) except Exception as e: - logger.error(f"Discovery error: {e}") + logger.error("Discovery error: %s", e) finally: self._running = False if sock: try: sock.close() - except Exception: + except OSError: pass async def _discover_alternative(self, duration: float): @@ -233,48 +233,48 @@ class PrinterDiscoveryService: self._handle_response(data.decode("utf-8", errors="ignore"), addr[0]) except BlockingIOError: 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() if now - last_send >= 2.0: try: sock.sendto(SSDP_MSEARCH.encode(), (SSDP_ADDR, SSDP_PORT)) last_send = now - except Exception: + except OSError: pass 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: - logger.error(f"Alternative discovery error: {e}") + logger.error("Alternative discovery error: %s", e) finally: if sock: try: sock.close() - except Exception: + except OSError: pass def _handle_response(self, response: str, ip_address: str): """Parse SSDP response and extract printer info.""" # Check if it's a Bambu Lab printer response 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 # Extract USN (Unique Service Name) which contains the serial # Bambu format is just "USN: SERIALNUMBER" (no uuid: prefix) usn_match = re.search(r"USN:\s*(?:uuid:)?([^\s\r\n]+)", response, re.IGNORECASE) 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 serial = usn_match.group(1).strip() # Skip Bambuddy's own virtual printer (any model variant) 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 # Extract device name from LOCATION or DevName header @@ -308,7 +308,7 @@ class PrinterDiscoveryService: ) 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: @@ -360,11 +360,11 @@ class SubnetScanner: self._total = len(hosts) 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 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 batch_size = 50 @@ -377,11 +377,11 @@ class SubnetScanner: await asyncio.gather(*tasks, return_exceptions=True) 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 except ValueError as e: - logger.error(f"Invalid subnet format: {e}") + logger.error("Invalid subnet format: %s", e) return [] finally: self._running = False @@ -399,14 +399,14 @@ class SubnetScanner: return # 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 serial, name, model = await self._get_printer_info_ssdp(ip, timeout) # Skip Bambuddy's own virtual printer (any model variant) 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 printer = DiscoveredPrinter( @@ -461,11 +461,11 @@ class SubnetScanner: if model_match: 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 - 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 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.close() await writer.wait_closed() - logger.debug(f"Port {port} open on {ip}") + logger.debug("Port %s open on %s", port, ip) return True except TimeoutError: return False @@ -485,7 +485,7 @@ class SubnetScanner: except OSError as e: # Log first few errors to help debug network issues if self._scanned < 5: - logger.debug(f"OSError checking {ip}:{port}: {e}") + logger.debug("OSError checking %s:%s: %s", ip, port, e) return False def stop(self): @@ -549,11 +549,11 @@ class TasmotaScanner: self._total = len(hosts) 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 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 batch_size = 50 @@ -567,14 +567,14 @@ class TasmotaScanner: try: await asyncio.gather(*tasks, return_exceptions=True) 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)) - 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 except ValueError as e: - logger.error(f"Invalid IP address format: {e}") + logger.error("Invalid IP address format: %s", e) return [] finally: self._running = False @@ -603,7 +603,7 @@ class TasmotaScanner: power_response = await client.get(power_url) if power_response.status_code == 401: # 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 = { "ip_address": ip, "name": f"Tasmota ({ip})", @@ -621,7 +621,7 @@ class TasmotaScanner: # Check for Tasmota auth warning (returns 200 with WARNING) if "WARNING" in power_data: - logger.info(f"Discovered Tasmota at {ip} (requires auth)") + logger.info("Discovered Tasmota at %s (requires auth)", ip) device = { "ip_address": ip, "name": f"Tasmota ({ip})", @@ -638,7 +638,7 @@ class TasmotaScanner: return except Exception as e: - logger.debug(f"Error probing {ip}: {e}") + logger.debug("Error probing %s: %s", ip, e) return # It's a Tasmota device! Now get more info @@ -672,7 +672,7 @@ class TasmotaScanner: } 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: pass diff --git a/backend/app/services/external_camera.py b/backend/app/services/external_camera.py index b56fdd236..032244036 100644 --- a/backend/app/services/external_camera.py +++ b/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", ) 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 # Block link-local addresses (169.254.x.x) 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 # 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 sanitized = f"{scheme}://{hostname}{port_str}{path}{query}{fragment}" return sanitized - except Exception: + except ValueError: return None @@ -144,7 +144,7 @@ def list_usb_cameras() -> list[dict]: info["formats"] = list(set(formats)) 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 # 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: 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": return await _capture_mjpeg_frame(url, timeout) 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": return await _capture_usb_frame(url, timeout) else: - logger.warning(f"Unknown camera type: {camera_type}") + logger.warning("Unknown camera type: %s", camera_type) 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) if not device_match: - logger.error(f"Invalid USB device path format: {device}") + logger.error("Invalid USB device path format: %s", device) return None # Convert to integer to break taint chain - integers cannot contain path traversal # lgtm[py/path-injection] - device_num is validated integer 0-99 device_num = int(device_match.group(1)) # Safe: regex guarantees 1-2 digits 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 # Construct safe path from validated integer (completely untainted) safe_device_path = Path(f"/dev/video{device_num}") # lgtm[py/path-injection] 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 # 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: - logger.debug(f"Running USB capture: {' '.join(cmd)}") + logger.debug("Running USB capture: %s", " ".join(cmd)) process = await asyncio.create_subprocess_exec( *cmd, 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) 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 if not stdout or len(stdout) < 100: @@ -270,12 +270,12 @@ async def _capture_usb_frame(device: str, timeout: int) -> bytes | None: return stdout 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: process.kill() 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 @@ -289,7 +289,7 @@ async def _capture_mjpeg_frame(url: str, timeout: int) -> bytes | None: # Sanitize URL - returns reconstructed URL from validated components safe_url = _sanitize_camera_url(url, ("http", "https")) if not safe_url: - logger.error(f"Invalid MJPEG URL format: {url[:50]}...") + logger.error("Invalid MJPEG URL format: %s...", url[:50]) return None try: @@ -298,7 +298,7 @@ async def _capture_mjpeg_frame(url: str, timeout: int) -> bytes | None: session.get(safe_url) as response, ): if response.status != 200: - logger.error(f"MJPEG stream returned status {response.status}") + logger.error("MJPEG stream returned status %s", response.status) return None # 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 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 - 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 @@ -375,7 +375,7 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None: ) 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]}") return None @@ -386,12 +386,12 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None: return stdout 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: process.kill() 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 @@ -405,7 +405,7 @@ async def _capture_snapshot(url: str, timeout: int) -> bytes | None: # Sanitize URL - returns reconstructed URL from validated components safe_url = _sanitize_camera_url(url, ("http", "https")) if not safe_url: - logger.error(f"Invalid snapshot URL format: {url[:50]}...") + logger.error("Invalid snapshot URL format: %s...", url[:50]) return None try: @@ -414,7 +414,7 @@ async def _capture_snapshot(url: str, timeout: int) -> bytes | None: session.get(safe_url) as response, ): if response.status != 200: - logger.error(f"Snapshot URL returned status {response.status}") + logger.error("Snapshot URL returned status %s", response.status) return None data = await response.read() @@ -427,10 +427,10 @@ async def _capture_snapshot(url: str, timeout: int) -> bytes | None: return data except TimeoutError: - logger.warning(f"Snapshot capture timed out after {timeout}s") + logger.warning("Snapshot capture timed out after %ss", timeout) 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 @@ -441,11 +441,11 @@ async def test_connection(url: str, camera_type: str) -> dict: Dict with {success: bool, error?: str, resolution?: str} """ 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: frame = await capture_frame(url, camera_type, timeout=10) 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: # 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] resolution = f"{width}x{height}" break - except Exception: + except (IndexError, ValueError): pass return {"success": True, "resolution": resolution} @@ -471,7 +471,7 @@ async def test_connection(url: str, camera_type: str) -> dict: except Exception as e: # Sanitize error message - don't expose internal details 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}"} @@ -517,8 +517,8 @@ async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> As await asyncio.sleep(frame_interval) except asyncio.CancelledError: 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) @@ -542,14 +542,14 @@ async def _stream_mjpeg(url: str) -> AsyncGenerator[bytes, None]: # Sanitize URL - returns reconstructed URL from validated components safe_url = _sanitize_camera_url(url, ("http", "https")) if not safe_url: - logger.error(f"Invalid MJPEG stream URL: {url[:50]}...") + logger.error("Invalid MJPEG stream URL: %s...", url[:50]) return try: timeout = aiohttp.ClientTimeout(total=None, sock_read=30) async with aiohttp.ClientSession(timeout=timeout) as session, session.get(safe_url) as response: if response.status != 200: - logger.error(f"MJPEG stream returned status {response.status}") + logger.error("MJPEG stream returned status %s", response.status) return buffer = b"" @@ -579,8 +579,8 @@ async def _stream_mjpeg(url: str) -> AsyncGenerator[bytes, None]: except asyncio.CancelledError: 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]: @@ -627,7 +627,7 @@ async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]: await asyncio.sleep(0.5) if process.returncode is not None: 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 buffer = b"" @@ -667,8 +667,8 @@ async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]: except asyncio.CancelledError: 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: if process and process.returncode is None: process.terminate() @@ -688,11 +688,11 @@ async def _stream_usb(device: str, fps: int) -> AsyncGenerator[bytes, None]: # Validate device path if not device.startswith("/dev/video"): - logger.error(f"Invalid USB device path: {device}") + logger.error("Invalid USB device path: %s", device) return if not Path(device).exists(): - logger.error(f"USB device does not exist: {device}") + logger.error("USB device does not exist: %s", device) return # 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 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( *cmd, stdout=asyncio.subprocess.PIPE, @@ -726,7 +726,7 @@ async def _stream_usb(device: str, fps: int) -> AsyncGenerator[bytes, None]: await asyncio.sleep(0.5) if process.returncode is not None: 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 buffer = b"" @@ -766,8 +766,8 @@ async def _stream_usb(device: str, fps: int) -> AsyncGenerator[bytes, None]: except asyncio.CancelledError: 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: if process and process.returncode is None: process.terminate() diff --git a/backend/app/services/firmware_check.py b/backend/app/services/firmware_check.py index 6bcca67fe..98c3dedc0 100644 --- a/backend/app/services/firmware_check.py +++ b/backend/app/services/firmware_check.py @@ -100,11 +100,11 @@ class FirmwareCheckService: if match: self._build_id = match.group(1) 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 - 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 @@ -135,10 +135,10 @@ class FirmwareCheckService: release_time=latest.get("release_time"), ) 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 @@ -167,7 +167,7 @@ class FirmwareCheckService: api_key = MODEL_TO_API_KEY.get(model) if not api_key: - logger.debug(f"Unknown printer model: {model}") + logger.debug("Unknown printer model: %s", model) return None # Check cache @@ -231,7 +231,7 @@ class FirmwareCheckService: result["update_available"] = latest_parts > current_parts 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 @@ -304,13 +304,13 @@ class FirmwareCheckService: """ latest = await self.get_latest_version(model) 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 # Check if already cached cached_path = self._get_cached_firmware_path(model, latest.version) if cached_path.exists(): - logger.info(f"Using cached firmware: {cached_path}") + logger.info("Using cached firmware: %s", cached_path) return cached_path # 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}" try: - logger.info(f"Downloading firmware from {latest.download_url}") + logger.info("Downloading firmware from %s", latest.download_url) if progress_callback: progress_callback(0, 0, "Starting download...") async with self._client.stream("GET", latest.download_url) as response: 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 total_size = int(response.headers.get("content-length", 0)) @@ -351,18 +351,18 @@ class FirmwareCheckService: shutil.copy2(temp_path, cached_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: progress_callback(downloaded, total_size, "Download complete") 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(): try: temp_path.unlink() - except Exception: + except OSError: pass return None diff --git a/backend/app/services/firmware_update.py b/backend/app/services/firmware_update.py index fdf26760f..2a8bb7a09 100644 --- a/backend/app/services/firmware_update.py +++ b/backend/app/services/firmware_update.py @@ -144,7 +144,7 @@ class FirmwareUpdateService: if storage_info and "free_bytes" in storage_info: result["sd_card_free_space"] = storage_info["free_bytes"] 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 firmware_service = get_firmware_service() @@ -211,7 +211,7 @@ class FirmwareUpdateService: # Check if already in progress 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 # Get printer @@ -285,7 +285,7 @@ class FirmwareUpdateService: # Upload to root of SD card (where printer expects firmware) 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 loop = asyncio.get_event_loop() @@ -341,10 +341,10 @@ class FirmwareUpdateService: ) 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: - 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.error = str(e) state.message = f"Firmware upload failed: {e}" diff --git a/backend/app/services/github_backup.py b/backend/app/services/github_backup.py index b137f3a66..ed8a0d7dd 100644 --- a/backend/app/services/github_backup.py +++ b/backend/app/services/github_backup.py @@ -71,7 +71,7 @@ class GitHubBackupService: except asyncio.CancelledError: break 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) async def _check_scheduled_backups(self): @@ -92,7 +92,7 @@ class GitHubBackupService: if next_run and next_run.tzinfo is None: next_run = next_run.replace(tzinfo=timezone.utc) 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") def _calculate_next_run(self, schedule_type: str, from_time: datetime | None = None) -> datetime: @@ -164,7 +164,7 @@ class GitHubBackupService: } 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 error_type = type(e).__name__ return { @@ -283,7 +283,7 @@ class GitHubBackupService: } except Exception as e: - logger.error(f"Backup failed: {e}") + logger.error("Backup failed: %s", e) log.status = "failed" log.completed_at = datetime.now(timezone.utc) log.error_message = str(e) @@ -393,10 +393,10 @@ class GitHubBackupService: files[f"kprofiles/{serial}/{nozzle}.json"] = profile_data printer_profiles[nozzle] = len(profiles) 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: - 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): """Collect Bambu Cloud profiles if authenticated.""" @@ -456,7 +456,7 @@ class GitHubBackupService: ) 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): """Collect app settings.""" @@ -551,7 +551,7 @@ class GitHubBackupService: ) 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 blob_sha = blob_response.json()["sha"] @@ -604,7 +604,7 @@ class GitHubBackupService: } 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)} async def _create_branch_and_push( diff --git a/backend/app/services/homeassistant.py b/backend/app/services/homeassistant.py index 45ba70920..e63deddfd 100644 --- a/backend/app/services/homeassistant.py +++ b/backend/app/services/homeassistant.py @@ -64,29 +64,29 @@ class HomeAssistantService: "reachable": True, "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} async def turn_on(self, plug: "SmartPlug") -> bool: """Turn on HA entity. Returns True if successful.""" success = await self._call_service(plug, "turn_on") 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 async def turn_off(self, plug: "SmartPlug") -> bool: """Turn off HA entity. Returns True if successful.""" success = await self._call_service(plug, "turn_off") 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 async def toggle(self, plug: "SmartPlug") -> bool: """Toggle HA entity. Returns True if successful.""" success = await self._call_service(plug, "toggle") 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 async def _call_service(self, plug: "SmartPlug", action: str) -> bool: @@ -105,8 +105,8 @@ class HomeAssistantService: ) response.raise_for_status() 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 async def get_energy(self, plug: "SmartPlug") -> dict | None: @@ -165,7 +165,8 @@ class HomeAssistantService: "apparent_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 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") if state and state not in ("unknown", "unavailable"): return float(state) - except Exception: + except (httpx.HTTPError, OSError, ValueError): pass return None @@ -265,8 +266,8 @@ class HomeAssistantService: ) 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 [] 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()) - 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 [] diff --git a/backend/app/services/layer_timelapse.py b/backend/app/services/layer_timelapse.py index 6ddec9b0d..b9a9d42e2 100644 --- a/backend/app/services/layer_timelapse.py +++ b/backend/app/services/layer_timelapse.py @@ -48,7 +48,7 @@ class TimelapseSession: def __post_init__(self): self.frames_dir = settings.base_dir / "timelapse_frames" / str(self.printer_id) / self.session_id 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: """Capture frame if layer changed. @@ -71,13 +71,15 @@ class TimelapseSession: frame_path = self.frames_dir / f"layer_{layer_num:05d}.jpg" await asyncio.to_thread(frame_path.write_bytes, frame_data) 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 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 except Exception as e: - logger.error(f"Error capturing timelapse frame: {e}") + logger.error("Error capturing timelapse frame: %s", e) return False async def stitch(self, output_path: Path, fps: int = 30) -> bool: @@ -118,7 +120,7 @@ class TimelapseSession: if frame_files: f.write(f"file '{frame_files[-1].name}'\n") except Exception as e: - logger.error(f"Failed to create concat file: {e}") + logger.error("Failed to create concat file: %s", e) return False # 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) 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 - 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 except TimeoutError: @@ -165,7 +167,7 @@ class TimelapseSession: process.kill() return False except Exception as e: - logger.error(f"Timelapse stitch failed: {e}") + logger.error("Timelapse stitch failed: %s", e) return False def cleanup(self): @@ -173,9 +175,9 @@ class TimelapseSession: try: if self.frames_dir.exists(): 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: - 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: @@ -200,7 +202,7 @@ def start_session(printer_id: int, archive_id: int | None, url: str, cam_type: s camera_type=cam_type, ) _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 @@ -235,7 +237,7 @@ async def on_print_complete(printer_id: int) -> Path | None: return None 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() return None @@ -252,7 +254,7 @@ async def on_print_complete(printer_id: int) -> Path | None: session.cleanup() return None except Exception as e: - logger.error(f"Timelapse completion failed: {e}") + logger.error("Timelapse completion failed: %s", e) session.cleanup() return None @@ -266,7 +268,7 @@ def cancel_session(printer_id: int): session = _active_sessions.pop(printer_id, None) if session: 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]: diff --git a/backend/app/services/mqtt_relay.py b/backend/app/services/mqtt_relay.py index f55657125..c2d72d673 100644 --- a/backend/app/services/mqtt_relay.py +++ b/backend/app/services/mqtt_relay.py @@ -89,7 +89,7 @@ class MQTTRelayService: await self._smart_plug_service.configure(settings) 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 def smart_plug_service(self): @@ -128,7 +128,7 @@ class MQTTRelayService: try: await asyncio.wait_for(asyncio.to_thread(self.client.connect_async, broker, port, 60), timeout=3.0) 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 self.client.loop_start() @@ -137,16 +137,16 @@ class MQTTRelayService: await asyncio.sleep(1.0) 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 self._publish_status("online") return True 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 except Exception as e: - logger.error(f"MQTT relay connection failed: {e}") + logger.error("MQTT relay connection failed: %s", e) self.connected = False return False @@ -168,7 +168,7 @@ class MQTTRelayService: self._publish_status("online") else: self.connected = False - logger.error(f"MQTT relay connection failed: {reason_code}") + logger.error("MQTT relay connection failed: %s", reason_code) def _on_disconnect( self, @@ -184,7 +184,7 @@ class MQTTRelayService: 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) if rc_val != 0: - logger.warning(f"MQTT relay disconnected: {rc}") + logger.warning("MQTT relay disconnected: %s", rc) else: logger.info("MQTT relay disconnected cleanly") @@ -197,7 +197,7 @@ class MQTTRelayService: self.client.loop_stop() self.client.disconnect() except Exception as e: - logger.debug(f"MQTT disconnect error (ignored): {e}") + logger.debug("MQTT disconnect error (ignored): %s", e) finally: self.client = None self.connected = False @@ -219,7 +219,7 @@ class MQTTRelayService: with self._lock: self.client.publish(topic, json.dumps(payload, default=str), qos=1, retain=retain) except Exception as e: - logger.debug(f"MQTT publish error: {e}") + logger.debug("MQTT publish error: %s", e) def get_status(self) -> dict: """Get current MQTT relay status for API.""" diff --git a/backend/app/services/mqtt_smart_plug.py b/backend/app/services/mqtt_smart_plug.py index 2ed44ce33..ab3e0bbd8 100644 --- a/backend/app/services/mqtt_smart_plug.py +++ b/backend/app/services/mqtt_smart_plug.py @@ -152,7 +152,7 @@ class MQTTSmartPlugService: timeout=3.0, ) 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 self.client.loop_start() @@ -161,16 +161,16 @@ class MQTTSmartPlugService: await asyncio.sleep(1.0) 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 self._resubscribe_all() return True 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 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 return False @@ -191,7 +191,7 @@ class MQTTSmartPlugService: self._resubscribe_all() else: 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( self, @@ -206,7 +206,7 @@ class MQTTSmartPlugService: 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) if rc_val != 0: - logger.warning(f"MQTT smart plug service disconnected: {rc}") + logger.warning("MQTT smart plug service disconnected: %s", rc) else: logger.info("MQTT smart plug service disconnected cleanly") @@ -244,7 +244,7 @@ class MQTTSmartPlugService: raw_value = payload else: # 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 else: # Raw value (non-JSON) @@ -264,14 +264,14 @@ class MQTTSmartPlugService: if data_type == "power": try: 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): pass elif data_type == "energy": try: 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): pass @@ -293,7 +293,7 @@ class MQTTSmartPlugService: data.state = "OFF" else: 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: """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 try: 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: - 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( self, @@ -415,9 +415,9 @@ class MQTTSmartPlugService: if self.client and self.connected: try: 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: - 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) if entry not in self.subscriptions[topic]: @@ -450,9 +450,9 @@ class MQTTSmartPlugService: if self.client and self.connected: try: 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: - 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 self.plug_data.pop(plug_id, None) @@ -478,7 +478,7 @@ class MQTTSmartPlugService: self.client.loop_stop() self.client.disconnect() 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: self.client = None self.connected = False diff --git a/backend/app/services/network_utils.py b/backend/app/services/network_utils.py index b6dd90238..3fd937022 100644 --- a/backend/app/services/network_utils.py +++ b/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 pass 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: # fcntl not available (Windows) logger.warning("fcntl not available, interface detection limited") except Exception as e: - logger.error(f"Error enumerating interfaces: {e}") + logger.error("Error enumerating interfaces: %s", e) return interfaces @@ -88,7 +88,7 @@ def find_interface_for_ip(target_ip: str) -> dict | None: try: target = ipaddress.IPv4Address(target_ip) except ValueError: - logger.error(f"Invalid target IP: {target_ip}") + logger.error("Invalid target IP: %s", target_ip) return None interfaces = get_network_interfaces() @@ -97,12 +97,12 @@ def find_interface_for_ip(target_ip: str) -> dict | None: try: network = ipaddress.IPv4Network(iface["subnet"], strict=False) 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 except ValueError: 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 diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index cf198c07e..4b229030a 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -67,7 +67,7 @@ class NotificationService: # Same day quiet hours return start_minutes <= current_time < end_minutes 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 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) if not template: # 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) title = self._render_template(template.title_template, variables) @@ -165,7 +165,7 @@ class NotificationService: else: return False, f"Unknown provider type: {provider_type}" 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) async def _send_callmebot(self, config: dict, message: str) -> tuple[bool, str]: @@ -432,7 +432,7 @@ class NotificationService: """Send notification to a specific provider.""" # Check quiet hours 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" config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config @@ -455,7 +455,7 @@ class NotificationService: else: return False, f"Unknown provider type: {provider.provider_type}" 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) async def _update_provider_status( @@ -520,7 +520,7 @@ class NotificationService: db.add(log) await db.commit() 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 async def _send_to_providers( @@ -569,11 +569,11 @@ class NotificationService: printer_name=printer_name, ) if success: - logger.info(f"Sent notification via {provider.name}") + logger.info("Sent notification via %s", provider.name) 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: - 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._log_notification( db=db, @@ -604,10 +604,10 @@ class NotificationService: db: Database session 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) 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 # 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) if archive_data and archive_data.get("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 if estimated_time is None: estimated_time = data.get("remaining_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) if estimated_time is None: raw_time = data.get("raw_data", {}).get("mc_remaining_time") if raw_time: 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) @@ -655,7 +655,7 @@ class NotificationService: if archive_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) await self._send_to_providers( 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, ): """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 if status == "completed": @@ -684,13 +684,13 @@ class NotificationService: event_field = "on_print_stopped" event_type = "print_stopped" 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_type = "print_complete" providers = await self._get_providers_for_event(db, event_field, printer_id) 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 # Use subtask_name (project name) if available, otherwise use filename @@ -723,7 +723,7 @@ class NotificationService: if archive_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) await self._send_to_providers( 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) 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 # Format maintenance items list @@ -866,7 +866,7 @@ class NotificationService: "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) 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) 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: - 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): """Send all queued notifications as a single digest for a provider.""" @@ -1181,7 +1181,7 @@ class NotificationService: queue_entries = list(result.scalars().all()) 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 # Build digest message @@ -1227,9 +1227,9 @@ class NotificationService: await db.commit() 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: - 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): """Check all providers and send digests if it's their scheduled time.""" @@ -1257,7 +1257,7 @@ class NotificationService: try: await self.send_digest(provider.id) 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): """Start the background scheduler for daily digest notifications.""" @@ -1278,7 +1278,7 @@ class NotificationService: try: await self.check_and_send_digests() 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 await asyncio.sleep(60) diff --git a/backend/app/services/plate_detection.py b/backend/app/services/plate_detection.py index 3052ddda7..d729b9f12 100644 --- a/backend/app/services/plate_detection.py +++ b/backend/app/services/plate_detection.py @@ -111,7 +111,7 @@ class PlateDetector: try: with open(meta_path) as f: return json.load(f) - except Exception: + except (json.JSONDecodeError, OSError, KeyError, ValueError): pass return {"references": {}} @@ -149,7 +149,7 @@ class PlateDetector: # Delete slot 0 slot0 = _get_calibration_dir() / f"printer_{printer_id}_ref_0.jpg" if slot0.exists(): - logger.info(f"Rotating references: removing oldest {slot0}") + logger.info("Rotating references: removing oldest %s", slot0) slot0.unlink() # Shift others down for i in range(1, self.MAX_REFERENCES): @@ -222,7 +222,7 @@ class PlateDetector: return False # 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() # Remove from metadata @@ -275,7 +275,7 @@ class PlateDetector: _, buffer = cv2.imencode(".jpg", thumb, [cv2.IMWRITE_JPEG_QUALITY, 80]) return buffer.tobytes() except Exception as e: - logger.error(f"Error creating thumbnail: {e}") + logger.error("Error creating thumbnail: %s", e) return None 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]) 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 # Verify the file actually exists and has content 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 file_size = reference_path.stat().st_size 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 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 metadata = self._load_metadata(printer_id) @@ -397,7 +397,7 @@ class PlateDetector: return False for path in paths: 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 def analyze_frame( @@ -607,9 +607,9 @@ async def capture_camera_image( image_data = await capture_frame(external_camera_url, external_camera_type) if image_data: 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: - 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 if image_data is None: @@ -622,9 +622,9 @@ async def capture_camera_image( if buffered: image_data = 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: - 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 image_data is None: @@ -641,11 +641,11 @@ async def capture_camera_image( with open(tmp_path, "rb") as f: image_data = f.read() 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: try: tmp_path.unlink() - except Exception: + except OSError: pass return image_data, camera_source diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index 47c59908e..8c2ff818a 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -45,7 +45,7 @@ class PrintScheduler: try: await self.check_queue() except Exception as e: - logger.error(f"Scheduler error: {e}") + logger.error("Scheduler error: %s", e) await asyncio.sleep(self._check_interval) @@ -93,13 +93,13 @@ class PrintScheduler: if not printer_connected: plug = await self._get_smart_plug(db, item.printer_id) 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) if powered_on: printer_connected = True printer_idle = self._is_printer_idle(item.printer_id) 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) continue else: @@ -119,7 +119,7 @@ class PrintScheduler: item.error_message = "Previous print failed or was aborted" item.completed_at = datetime.now() 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 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.completed_at = datetime.now() 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 job_name = await self._get_job_name(db, item) @@ -192,7 +192,7 @@ class PrintScheduler: # Assign printer and start - clear waiting reason item.printer_id = printer_id 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 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) if 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 # Found a matching printer - clear waiting reason @@ -366,19 +366,19 @@ class PrintScheduler: # Get printer status status = printer_manager.get_status(printer_id) 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 # Get filament requirements from source file filament_reqs = await self._get_filament_requirements(db, item) 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 # Build loaded filaments from printer status loaded_filaments = self._build_loaded_filaments(status) 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 # Compute mapping: match required filaments to available slots @@ -478,7 +478,7 @@ class PrintScheduler: filaments.sort(key=lambda x: x["slot_id"]) 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 filaments if filaments else None @@ -713,48 +713,48 @@ class PrintScheduler: # Check current plug state status = await service.get_status(plug) 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 # Turn on if not already on if status.get("state") != "ON": success = await service.turn_on(plug) 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 - 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 result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() 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 # 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) # Try to connect to the printer periodically elapsed = 30 # Already waited 30s while elapsed < self._power_on_wait_time: # Try to connect - logger.info(f"Attempting to connect to printer {printer_id}...") + logger.info("Attempting to connect to printer %s...", printer_id) try: connected = await printer_manager.connect_printer(printer) 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 await asyncio.sleep(5) return True 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) 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 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) 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) 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) await service.turn_off(plug) @@ -816,7 +816,7 @@ class PrintScheduler: - archive_id: Print from an existing archive - 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) 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.completed_at = datetime.utcnow() 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) return @@ -836,7 +836,7 @@ class PrintScheduler: item.error_message = "Printer not connected" item.completed_at = datetime.utcnow() 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) return @@ -855,7 +855,7 @@ class PrintScheduler: item.error_message = "Archive not found" item.completed_at = datetime.utcnow() 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) return @@ -892,7 +892,7 @@ class PrintScheduler: item.error_message = "Library file not found" item.completed_at = datetime.utcnow() 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) return # Library files store absolute paths @@ -908,7 +908,7 @@ class PrintScheduler: item.error_message = "No source file specified" item.completed_at = datetime.utcnow() 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) return @@ -918,7 +918,7 @@ class PrintScheduler: item.error_message = "Source file not found on disk" item.completed_at = datetime.utcnow() 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) return @@ -945,7 +945,7 @@ class PrintScheduler: # Delete existing file if present (avoids 553 error on overwrite) 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( printer.ip_address, printer.access_code, @@ -953,9 +953,9 @@ class PrintScheduler: socket_timeout=ftp_timeout, 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: - 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: if ftp_retry_enabled: @@ -982,7 +982,7 @@ class PrintScheduler: ) except Exception as e: 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: error_msg = ( @@ -1022,7 +1022,7 @@ class PrintScheduler: try: ams_mapping = json.loads(item.ams_mapping) 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. # This prevents phantom reprints if the backend crashes/restarts after the @@ -1033,7 +1033,7 @@ class PrintScheduler: item.status = "printing" item.started_at = datetime.utcnow() 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 started = printer_manager.start_print( @@ -1050,7 +1050,7 @@ class PrintScheduler: ) 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 estimated_time = None diff --git a/backend/app/services/printer_manager.py b/backend/app/services/printer_manager.py index c0e6e8a81..3cb2743db 100644 --- a/backend/app/services/printer_manager.py +++ b/backend/app/services/printer_manager.py @@ -269,7 +269,7 @@ class PrinterManager: if printer_id in self._clients: client = self._clients[printer_id] 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.state = "unknown" # Trigger the status change callback to broadcast via WebSocket @@ -336,7 +336,7 @@ class PrinterManager: while elapsed < timeout: state = self.get_status(printer_id) 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 # Check nozzle temperature (and nozzle_2 for dual extruders) @@ -345,14 +345,14 @@ class PrinterManager: max_temp = max(nozzle_temp, nozzle_2_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 - 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) 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 def enable_logging(self, printer_id: int, enabled: bool = True) -> bool: diff --git a/backend/app/services/smart_plug_manager.py b/backend/app/services/smart_plug_manager.py index e788068a9..592259756 100644 --- a/backend/app/services/smart_plug_manager.py +++ b/backend/app/services/smart_plug_manager.py @@ -63,7 +63,7 @@ class SmartPlugManager: ha_token = ha_token_setting.value if ha_token_setting else "" homeassistant_service.configure(ha_url, ha_token) 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): """Set the event loop for async operations.""" @@ -88,7 +88,7 @@ class SmartPlugManager: try: await self._check_schedules() 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 await asyncio.sleep(60) @@ -116,7 +116,7 @@ class SmartPlugManager: if plug.schedule_on_time == current_time: last_check = self._last_schedule_check.get(plug.id) 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) if success: plug.last_state = "ON" @@ -127,7 +127,7 @@ class SmartPlugManager: if plug.schedule_off_time == current_time: last_check = self._last_schedule_check.get(plug.id) 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) if success: plug.last_state = "OFF" @@ -154,18 +154,18 @@ class SmartPlugManager: return 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 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 # Cancel any pending off task self._cancel_pending_off(plug.id) # 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) success = await service.turn_on(plug) @@ -188,16 +188,16 @@ class SmartPlugManager: return 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 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 # 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."): - 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 # Only auto-off on successful completion, not on failures @@ -209,7 +209,9 @@ class SmartPlugManager: ) 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": 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 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) asyncio.create_task(self._mark_auto_off_pending(plug.id, True)) @@ -268,7 +270,7 @@ class SmartPlugManager: plug_info = PlugInfo() service = await self.get_service_for_plug(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 if success: @@ -277,7 +279,7 @@ class SmartPlugManager: printer_manager.mark_printer_offline(printer_id) 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: self._pending_off.pop(plug_id, None) @@ -286,7 +288,7 @@ class SmartPlugManager: # Cancel any existing task for this plug 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) asyncio.create_task(self._mark_auto_off_pending(plug.id, True)) @@ -344,7 +346,9 @@ class SmartPlugManager: f"threshold={temp_threshold}°C" ) 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: # All nozzles are below threshold, turn off @@ -377,10 +381,10 @@ class SmartPlugManager: elapsed += check_interval 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: - 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: self._pending_off.pop(plug_id, None) @@ -397,9 +401,9 @@ class SmartPlugManager: plug.auto_off_pending = pending plug.auto_off_pending_since = datetime.utcnow() if pending else None 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: - 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): """Disable auto-off after it was executed (one-shot behavior).""" @@ -418,14 +422,14 @@ class SmartPlugManager: plug.last_state = "OFF" plug.last_checked = datetime.utcnow() 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: - 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): """Cancel any pending off task for this plug.""" 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() del self._pending_off[plug_id] # Clear pending state in database @@ -470,14 +474,14 @@ class SmartPlugManager: await db.commit() 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 if plug.off_delay_mode == "temperature": self._schedule_temp_based_off(plug, plug.printer_id, plug.off_temp_threshold) else: # 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) success = await service.turn_off(plug) @@ -486,10 +490,10 @@ class SmartPlugManager: printer_manager.mark_printer_offline(plug.printer_id) 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: - logger.warning(f"Failed to resume pending auto-offs: {e}") + logger.warning("Failed to resume pending auto-offs: %s", e) # Global singleton diff --git a/backend/app/services/spoolman.py b/backend/app/services/spoolman.py index 94413720e..e1c835d35 100644 --- a/backend/app/services/spoolman.py +++ b/backend/app/services/spoolman.py @@ -91,7 +91,7 @@ class SpoolmanClient: self._connected = response.status_code == 200 return self._connected except Exception as e: - logger.warning(f"Spoolman health check failed: {e}") + logger.warning("Spoolman health check failed: %s", e) self._connected = False return False @@ -112,7 +112,7 @@ class SpoolmanClient: response.raise_for_status() return response.json() 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 [] async def get_filaments(self) -> list[dict]: @@ -127,7 +127,7 @@ class SpoolmanClient: response.raise_for_status() return response.json() 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 [] async def get_external_filaments(self) -> list[dict]: @@ -142,7 +142,7 @@ class SpoolmanClient: response.raise_for_status() return response.json() 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 [] async def get_vendors(self) -> list[dict]: @@ -157,7 +157,7 @@ class SpoolmanClient: response.raise_for_status() return response.json() 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 [] async def create_vendor(self, name: str) -> dict | None: @@ -175,7 +175,7 @@ class SpoolmanClient: response.raise_for_status() return response.json() 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 def _get_material_density(self, material: str | None) -> float: @@ -262,16 +262,16 @@ class SpoolmanClient: if 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() response = await client.post(f"{self.api_url}/filament", json=data) response.raise_for_status() return response.json() 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 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 async def create_spool( @@ -309,18 +309,18 @@ class SpoolmanClient: if 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() response = await client.post(f"{self.api_url}/spool", json=data) response.raise_for_status() 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 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 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 async def update_spool( @@ -362,7 +362,7 @@ class SpoolmanClient: response.raise_for_status() return response.json() 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 async def use_spool(self, spool_id: int, used_weight: float) -> dict | None: @@ -384,7 +384,7 @@ class SpoolmanClient: response.raise_for_status() return response.json() 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 async def find_spool_by_tag(self, tag_uid: str) -> dict | None: @@ -408,7 +408,7 @@ class SpoolmanClient: if stored_tag: normalized_tag = stored_tag.strip('"').upper() 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 None @@ -517,11 +517,11 @@ class SpoolmanClient: logger.info("Created 'tag' extra field in Spoolman") 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 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 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 # e.g., GFA00, GFB00, GFL00, GFN00, GFG00, GFS00, GFU00 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 # Check tray_uuid (preferred - consistent across printer models) @@ -643,7 +643,7 @@ class SpoolmanClient: if len(tag) == 16 and tag != "0000000000000000": try: 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 except ValueError: 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})" ) 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 # 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) if existing: # 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( spool_id=existing["id"], remaining_weight=None if disable_weight_sync else remaining, @@ -727,12 +727,12 @@ class SpoolmanClient: ) # 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 filament = await self._find_or_create_filament(tray) 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 # Create the spool with identifier stored as "tag" in extra field diff --git a/backend/app/services/spoolman_tracking.py b/backend/app/services/spoolman_tracking.py index c538a1b8d..8e8fbcd66 100644 --- a/backend/app/services/spoolman_tracking.py +++ b/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 full_path = app_settings.base_dir / file_path 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 # Extract per-filament usage from 3MF (total usage per slot) filament_usage = extract_filament_usage_from_3mf(full_path) 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 # 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: # Convert int keys to string for JSON serialization 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 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) 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: - logger.debug(f"[SPOOLMAN] Custom slot mapping: {slot_to_tray}") + logger.debug("[SPOOLMAN] Custom slot mapping: %s", slot_to_tray) if layer_usage_json: 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() 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 # Try to report partial usage before cleanup try: await _report_partial_usage(printer_id, tracking) 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 await db.execute( @@ -203,7 +203,7 @@ async def cleanup_tracking(printer_id: int, archive_id: int, db): .where(ActivePrintSpoolman.archive_id == archive_id) ) 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(): @@ -245,22 +245,22 @@ async def _report_spool_usage_for_slots( global_tray_id = _resolve_global_tray_id(slot_id, slot_to_tray) tray_info = ams_trays.get(global_tray_id) 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 spool_tag = _resolve_spool_tag(tray_info) 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 spool = await client.find_spool_by_tag(spool_tag) 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 result = await client.use_spool(spool["id"], grams_used) 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 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)") 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 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) 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 usage_items = [] @@ -350,7 +350,7 @@ async def _report_partial_usage(printer_id: int, tracking): if not density: props = filament_properties.get(str(slot_id), filament_properties.get(slot_id, {})) 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) 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)" ) 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 # Fallback: linear interpolation (if no G-code data available) 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 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 = [] 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)" ) 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): @@ -404,7 +404,7 @@ async def report_usage(printer_id: int, archive_id: int): tracking = result.scalar_one_or_none() 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 filament_usage = tracking.filament_usage or [] @@ -416,7 +416,7 @@ async def report_usage(printer_id: int, archive_id: int): await db.commit() 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 # 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") 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] 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: - logger.info(f"[SPOOLMAN] Archive {archive_id}: no spools updated") + logger.info("[SPOOLMAN] Archive %s: no spools updated", archive_id) 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) diff --git a/backend/app/services/stl_thumbnail.py b/backend/app/services/stl_thumbnail.py index 83b2a12a5..bea776ca0 100644 --- a/backend/app/services/stl_thumbnail.py +++ b/backend/app/services/stl_thumbnail.py @@ -46,12 +46,12 @@ def generate_stl_thumbnail( mesh = trimesh.load(str(stl_path), force="mesh") 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 # Simplify large meshes for performance 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: # Calculate reduction ratio (0-1 range) # 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) target_reduction = max(0.01, min(0.99, 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: - logger.warning(f"Mesh simplification failed, using original: {e}") + logger.warning("Mesh simplification failed, using original: %s", e) # Get mesh bounds and center it vertices = mesh.vertices @@ -129,12 +129,12 @@ def generate_stl_thumbnail( ) plt.close(fig) - logger.info(f"Generated STL thumbnail: {thumb_path}") + logger.info("Generated STL thumbnail: %s", thumb_path) return str(thumb_path) 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 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 diff --git a/backend/app/services/tasmota.py b/backend/app/services/tasmota.py index b522149da..b059959ca 100644 --- a/backend/app/services/tasmota.py +++ b/backend/app/services/tasmota.py @@ -48,16 +48,16 @@ class TasmotaService: response.raise_for_status() return response.json() except httpx.TimeoutException: - logger.warning(f"Tasmota device at {ip} timed out") + logger.warning("Tasmota device at %s timed out", ip) return None 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 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 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 async def get_status(self, plug: "SmartPlug") -> dict: @@ -95,9 +95,9 @@ class TasmotaService: success = state == "ON" 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: - 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 @@ -113,9 +113,9 @@ class TasmotaService: success = state == "OFF" 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: - 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 @@ -130,7 +130,7 @@ class TasmotaService: success = state in ["ON", "OFF"] 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 diff --git a/backend/app/services/timelapse_processor.py b/backend/app/services/timelapse_processor.py index 2abcbf451..3edd63af5 100644 --- a/backend/app/services/timelapse_processor.py +++ b/backend/app/services/timelapse_processor.py @@ -43,7 +43,7 @@ class TimelapseProcessor: stdout, stderr = await process.communicate() 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()}") 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 process = await asyncio.create_subprocess_exec( @@ -230,7 +230,7 @@ class TimelapseProcessor: _, stderr = await process.communicate() if process.returncode != 0: - logger.error(f"FFmpeg processing failed: {stderr.decode()}") + logger.error("FFmpeg processing failed: %s", stderr.decode()) return False return output_path.exists() diff --git a/backend/app/services/virtual_printer/certificate.py b/backend/app/services/virtual_printer/certificate.py index f8b8ad76f..6b9c5a5fb 100644 --- a/backend/app/services/virtual_printer/certificate.py +++ b/backend/app/services/virtual_printer/certificate.py @@ -36,7 +36,7 @@ def _get_local_ip() -> str: ip = s.getsockname()[0] s.close() return ip - except Exception: + except OSError: return "127.0.0.1" @@ -92,18 +92,18 @@ class CertificateService: now = datetime.now(timezone.utc) days_remaining = (ca_cert.not_valid_after_utc - now).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 # Load CA private key ca_key_pem = self.ca_key_path.read_bytes() 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 - 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 def _get_or_create_ca(self) -> tuple[rsa.RSAPrivateKey, x509.Certificate]: @@ -203,7 +203,7 @@ class CertificateService: Returns: 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 self.cert_dir.mkdir(parents=True, exist_ok=True) @@ -229,7 +229,7 @@ class CertificateService: now = datetime.now(timezone.utc) 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 printer_cert = ( @@ -298,9 +298,9 @@ class CertificateService: ) 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(f" Printer: CN={self.serial}") + logger.info(" Printer: CN=%s", self.serial) return self.cert_path, self.key_path def delete_printer_certificate(self) -> None: diff --git a/backend/app/services/virtual_printer/ftp_server.py b/backend/app/services/virtual_printer/ftp_server.py index aa217ef25..03234e55e 100644 --- a/backend/app/services/virtual_printer/ftp_server.py +++ b/backend/app/services/virtual_printer/ftp_server.py @@ -57,7 +57,7 @@ class FTPSession: async def send(self, code: int, message: str) -> None: """Send an FTP response.""" 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")) await self.writer.drain() @@ -74,7 +74,7 @@ class FTPSession: timeout=300, # 5 minute timeout ) except TimeoutError: - logger.debug(f"FTP session timeout from {self.remote_ip}") + logger.debug("FTP session timeout from %s", self.remote_ip) break if not line: @@ -88,7 +88,7 @@ class FTPSession: if not command_line: continue - logger.info(f"FTP <- {self.remote_ip}: {command_line}") + logger.info("FTP <- %s: %s", self.remote_ip, command_line) # Parse command and argument parts = command_line.split(" ", 1) @@ -100,15 +100,15 @@ class FTPSession: if handler: await handler(arg) 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") 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: - logger.error(f"FTP session error from {self.remote_ip}: {e}") + logger.error("FTP session error from %s: %s", self.remote_ip, e) finally: - logger.info(f"FTP session ended from {self.remote_ip}") + logger.info("FTP session ended from %s", self.remote_ip) await self._cleanup() async def _cleanup(self) -> None: @@ -117,14 +117,14 @@ class FTPSession: self.data_server.close() try: await self.data_server.wait_closed() - except Exception: + except OSError: pass self.data_server = None try: self.writer.close() await self.writer.wait_closed() - except Exception: + except OSError: pass # FTP Commands @@ -143,10 +143,10 @@ class FTPSession: if arg == self.access_code: self.authenticated = True 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: 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: await self.send(503, "Login with USER first") @@ -224,10 +224,10 @@ class FTPSession: # EPSV response format: 229 Entering Extended Passive Mode (|||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: - 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") async def cmd_PASV(self, arg: str) -> None: @@ -270,10 +270,10 @@ class FTPSession: 227, 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: - 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") 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}" ) 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_writer = writer self._data_connected.set() @@ -302,7 +302,7 @@ class FTPSession: try: self._data_writer.close() await self._data_writer.wait_closed() - except Exception: + except OSError: pass self._data_writer = None self._data_reader = None @@ -311,7 +311,7 @@ class FTPSession: try: self.data_server.close() await self.data_server.wait_closed() - except Exception: + except OSError: pass self.data_server = None @@ -332,7 +332,7 @@ class FTPSession: filename = Path(arg).name # Sanitize 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}") @@ -358,14 +358,14 @@ class FTPSession: if not chunk: break data_content.append(chunk) - logger.debug(f"FTP received chunk: {len(chunk)} bytes") + logger.debug("FTP received chunk: %s bytes", len(chunk)) except TimeoutError: logger.error("FTP data transfer timeout") await self.send(426, "Transfer timeout") await self._close_data_connection() return 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._close_data_connection() return @@ -377,7 +377,7 @@ class FTPSession: try: total_size = sum(len(c) for c in 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") # Notify callback @@ -387,10 +387,10 @@ class FTPSession: if asyncio.iscoroutine(result): await result except Exception as e: - logger.error(f"File received callback error: {e}") + logger.error("File received callback error: %s", 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") async def cmd_SIZE(self, arg: str) -> None: @@ -493,7 +493,7 @@ class VirtualPrinterFTPServer: if self._running: 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 self.upload_dir.mkdir(parents=True, exist_ok=True) @@ -520,27 +520,27 @@ class VirtualPrinterFTPServer: ) 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: await self._server.serve_forever() except OSError as e: 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: - logger.error(f"FTP server error: {e}") + logger.error("FTP server error: %s", e) except asyncio.CancelledError: logger.debug("FTP server task cancelled") except Exception as e: - logger.error(f"FTP server error: {e}") + logger.error("FTP server error: %s", e) finally: await self.stop() async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: """Handle a new FTP client connection.""" peername = writer.get_extra_info("peername") - logger.info(f"FTP connection from {peername}") + logger.info("FTP connection from %s", peername) session = FTPSession( reader=reader, @@ -580,6 +580,6 @@ class VirtualPrinterFTPServer: try: self._server.close() 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 diff --git a/backend/app/services/virtual_printer/manager.py b/backend/app/services/virtual_printer/manager.py index 9f3c27b98..4a90cd4db 100644 --- a/backend/app/services/virtual_printer/manager.py +++ b/backend/app/services/virtual_printer/manager.py @@ -133,7 +133,7 @@ class VirtualPrinterManager: 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: try: @@ -260,20 +260,20 @@ class VirtualPrinterManager: await self._stop() elif enabled and self._enabled and needs_restart: # 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() # Give time for ports to be released await asyncio.sleep(0.5) await self._start() logger.info("Virtual printer restarted with new configuration") 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 async def _start(self) -> None: """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 if self._mode == "proxy": @@ -285,12 +285,12 @@ class VirtualPrinterManager: async def _start_proxy_mode(self) -> None: """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 # This ensures MQTT topic subscriptions match the real printer's topics 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 self._cert_service.serial = proxy_serial @@ -298,7 +298,7 @@ class VirtualPrinterManager: # Regenerate printer cert if needed (CA is preserved) self._cert_service.delete_printer_certificate() 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 self._proxy = SlicerProxyManager( @@ -313,7 +313,7 @@ class VirtualPrinterManager: try: await coro except Exception as e: - logger.error(f"Virtual printer {name} failed: {e}") + logger.error("Virtual printer %s failed: %s", name, e) self._tasks = [] @@ -388,7 +388,7 @@ class VirtualPrinterManager: # Regenerate printer cert if serial changed (CA is preserved) self._cert_service.delete_printer_certificate() 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 self._upload_dir.mkdir(parents=True, exist_ok=True) @@ -423,7 +423,7 @@ class VirtualPrinterManager: try: await coro except Exception as e: - logger.error(f"Virtual printer {name} failed: {e}") + logger.error("Virtual printer %s failed: %s", name, e) self._tasks = [ 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"), ] - 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: """Handle proxy activity for logging.""" - logger.info(f"Proxy {name}: {message}") + logger.info("Proxy %s: %s", name, message) async def _stop(self) -> None: """Stop all virtual printer services.""" @@ -483,7 +483,7 @@ class VirtualPrinterManager: file_path: Path to uploaded file 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 self._pending_files[file_path.name] = file_path @@ -510,8 +510,8 @@ class VirtualPrinterManager: filename: Name of the file to print 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 # This command just confirms the slicer's intent to "print" @@ -529,12 +529,12 @@ class VirtualPrinterManager: # Only archive 3MF files 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 self._pending_files.pop(file_path.name, None) try: file_path.unlink() - except Exception: + except OSError: pass return @@ -556,21 +556,21 @@ class VirtualPrinterManager: ) 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) try: file_path.unlink() - except Exception: + except OSError: pass # Remove from pending self._pending_files.pop(file_path.name, None) 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: """Queue file for user review. @@ -585,7 +585,7 @@ class VirtualPrinterManager: # Only queue 3MF files 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 try: @@ -603,13 +603,13 @@ class VirtualPrinterManager: db.add(pending) 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 self._pending_files.pop(file_path.name, None) 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: """Archive file and add to print queue (unassigned). @@ -624,11 +624,11 @@ class VirtualPrinterManager: # Only process 3MF files 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) try: file_path.unlink() - except Exception: + except OSError: pass return @@ -651,7 +651,7 @@ class VirtualPrinterManager: ) 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) queue_item = PrintQueueItem( @@ -663,21 +663,23 @@ class VirtualPrinterManager: db.add(queue_item) 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) try: file_path.unlink() - except Exception: + except OSError: pass # Remove from pending self._pending_files.pop(file_path.name, None) 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: """Get virtual printer status. diff --git a/backend/app/services/virtual_printer/mqtt_server.py b/backend/app/services/virtual_printer/mqtt_server.py index 438289dbb..eb94122be 100644 --- a/backend/app/services/virtual_printer/mqtt_server.py +++ b/backend/app/services/virtual_printer/mqtt_server.py @@ -68,7 +68,7 @@ class VirtualPrinterMQTTServer: logger.error("amqtt not installed. Run: pip install amqtt") 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 config = { @@ -101,7 +101,7 @@ class VirtualPrinterMQTTServer: # Start the broker 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 while self._running: @@ -109,13 +109,13 @@ class VirtualPrinterMQTTServer: except OSError as e: 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: - logger.error(f"MQTT broker error: {e}") + logger.error("MQTT broker error: %s", e) except asyncio.CancelledError: logger.debug("MQTT broker task cancelled") except Exception as e: - logger.error(f"MQTT broker error: {e}") + logger.error("MQTT broker error: %s", e) finally: await self.stop() @@ -133,10 +133,10 @@ class VirtualPrinterMQTTServer: # Bambu slicers use 'bblp' as username and access code as password 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 - 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 async def stop(self) -> None: @@ -147,8 +147,8 @@ class VirtualPrinterMQTTServer: if self._broker: try: 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 @@ -186,7 +186,7 @@ class SimpleMQTTServer: if self._running: 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 ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) @@ -208,11 +208,11 @@ class SimpleMQTTServer: text=True, 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 - 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: self._running = True @@ -227,12 +227,12 @@ class SimpleMQTTServer: f"MQTT TLS connection from {addr} - cipher={ssl_obj.cipher()}, version={ssl_obj.version()}" ) 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) except ssl.SSLError as e: - logger.error(f"MQTT SSL error: {e}") + logger.error("MQTT SSL error: %s", 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 logger.info("Setting up MQTT server with SSL error handling...") @@ -242,9 +242,9 @@ class SimpleMQTTServer: exception = context.get("exception") message = context.get("message", "") 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: - logger.debug(f"Asyncio error: {message}") + logger.debug("Asyncio error: %s", message) asyncio.get_event_loop().set_exception_handler(handle_ssl_error) @@ -255,7 +255,7 @@ class SimpleMQTTServer: 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 self._status_push_task = asyncio.create_task(self._periodic_status_push()) @@ -265,13 +265,13 @@ class SimpleMQTTServer: except OSError as e: 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: - logger.error(f"MQTT server error: {e}") + logger.error("MQTT server error: %s", e) except asyncio.CancelledError: logger.debug("MQTT server task cancelled") except Exception as e: - logger.error(f"MQTT server error: {e}") + logger.error("MQTT server error: %s", e) finally: await self.stop() @@ -294,7 +294,7 @@ class SimpleMQTTServer: try: writer.close() await writer.wait_closed() - except Exception: + except OSError: pass self._clients.clear() @@ -302,7 +302,7 @@ class SimpleMQTTServer: try: self._server.close() await self._server.wait_closed() - except Exception: + except OSError: pass self._server = None @@ -321,8 +321,8 @@ class SimpleMQTTServer: disconnected.append(client_id) continue 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) # Remove disconnected clients @@ -332,7 +332,7 @@ class SimpleMQTTServer: except asyncio.CancelledError: break 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") @@ -340,7 +340,7 @@ class SimpleMQTTServer: """Handle an MQTT client connection.""" addr = writer.get_extra_info("peername") 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 @@ -388,15 +388,15 @@ class SimpleMQTTServer: except asyncio.CancelledError: pass except Exception as e: - logger.debug(f"MQTT client error: {e}") + logger.debug("MQTT client error: %s", e) finally: - logger.debug(f"MQTT client disconnected: {client_id}") + logger.debug("MQTT client disconnected: %s", client_id) if client_id in self._clients: del self._clients[client_id] try: writer.close() await writer.wait_closed() - except Exception: + except OSError: pass async def _read_remaining_length(self, reader: asyncio.StreamReader) -> int | None: @@ -414,7 +414,7 @@ class SimpleMQTTServer: if (encoded & 128) == 0: return value multiplier *= 128 - except Exception: + except OSError: return None return None @@ -469,11 +469,11 @@ class SimpleMQTTServer: # Send CONNACK with auth failure writer.write(bytes([0x20, 0x02, 0x00, 0x05])) # Not authorized await writer.drain() - logger.warning(f"MQTT auth failed for user '{username}'") + logger.warning("MQTT auth failed for user '%s'", username) 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 writer.write(bytes([0x20, 0x02, 0x00, 0x02])) # Protocol error await writer.drain() @@ -496,7 +496,7 @@ class SimpleMQTTServer: requested_qos = payload[idx] 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 # Send SUBACK @@ -508,8 +508,8 @@ class SimpleMQTTServer: # Send initial status report after subscribe 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: """Send a status report to the slicer after connection.""" @@ -620,10 +620,10 @@ class SimpleMQTTServer: writer.write(packet) 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: """Send version info response to the slicer.""" @@ -715,10 +715,10 @@ class SimpleMQTTServer: writer.write(packet) 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: """Handle MQTT PUBLISH packet.""" @@ -739,7 +739,7 @@ class SimpleMQTTServer: # Parse message 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 if f"device/{self.serial}/request" in topic: @@ -750,7 +750,7 @@ class SimpleMQTTServer: if "pushing" in data: pushing_data = data["pushing"] command = pushing_data.get("command", "") - logger.info(f"MQTT pushing command: {command}") + logger.info("MQTT pushing command: %s", command) if command == "pushall": # Slicer is requesting full status - send response @@ -766,7 +766,7 @@ class SimpleMQTTServer: info_data = data["info"] command = info_data.get("command", "") 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": await self._send_version_response(writer, sequence_id) @@ -777,7 +777,7 @@ class SimpleMQTTServer: command = print_data.get("command", "") 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": await self._notify_print_command(filename, print_data) @@ -785,8 +785,8 @@ class SimpleMQTTServer: except json.JSONDecodeError: 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: """Notify callback of print command.""" @@ -796,4 +796,4 @@ class SimpleMQTTServer: if asyncio.iscoroutine(result): await result except Exception as e: - logger.error(f"Print command callback error: {e}") + logger.error("Print command callback error: %s", e) diff --git a/backend/app/services/virtual_printer/ssdp_server.py b/backend/app/services/virtual_printer/ssdp_server.py index 221d1e194..6d94ef5c2 100644 --- a/backend/app/services/virtual_printer/ssdp_server.py +++ b/backend/app/services/virtual_printer/ssdp_server.py @@ -61,7 +61,7 @@ class VirtualPrinterSSDPServer: s.close() self._local_ip = ip return ip - except Exception: + except OSError: return "127.0.0.1" def _build_notify_message(self) -> bytes: @@ -128,7 +128,7 @@ class VirtualPrinterSSDPServer: if self._running: 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 try: @@ -159,8 +159,8 @@ class VirtualPrinterSSDPServer: self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2) 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 await self._send_notify() @@ -178,9 +178,9 @@ class VirtualPrinterSSDPServer: await self._handle_message(message, addr) except BlockingIOError: pass - except Exception as e: + except OSError as e: if self._running: - logger.debug(f"SSDP receive error: {e}") + logger.debug("SSDP receive error: %s", e) # Send periodic NOTIFY now = asyncio.get_event_loop().time() @@ -192,13 +192,13 @@ class VirtualPrinterSSDPServer: except OSError as e: 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: - logger.error(f"SSDP server error: {e}") + logger.error("SSDP server error: %s", e) except asyncio.CancelledError: logger.debug("SSDP server cancelled") except Exception as e: - logger.error(f"SSDP server error: {e}") + logger.error("SSDP server error: %s", e) finally: await self._cleanup() @@ -214,12 +214,12 @@ class VirtualPrinterSSDPServer: try: # Send byebye message await self._send_byebye() - except Exception: + except OSError: pass try: self._socket.close() - except Exception: + except OSError: pass self._socket = None @@ -232,9 +232,9 @@ class VirtualPrinterSSDPServer: msg = self._build_notify_message() # Real Bambu printers broadcast to 255.255.255.255, not multicast 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: """Send SSDP byebye message when shutting down.""" @@ -253,7 +253,7 @@ class VirtualPrinterSSDPServer: try: self._socket.sendto(message.encode(), (SSDP_BROADCAST_ADDR, SSDP_PORT)) logger.debug("Sent SSDP byebye") - except Exception: + except OSError: pass 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(): return - logger.debug(f"Received M-SEARCH from {addr[0]}") + logger.debug("Received M-SEARCH from %s", addr[0]) # Send response if self._socket: try: response = self._build_response_message() 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: @@ -341,13 +341,13 @@ class SSDPProxy: flags=re.IGNORECASE, ) 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: - 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") except Exception as e: - logger.error(f"Failed to rewrite SSDP: {e}") + logger.error("Failed to rewrite SSDP: %s", e) return data async def start(self) -> None: @@ -397,8 +397,10 @@ class SSDPProxy: self._remote_socket.bind((self.remote_interface_ip, 0)) 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 last_broadcast = 0.0 @@ -411,9 +413,9 @@ class SSDPProxy: await self._handle_local_packet(data, addr) except BlockingIOError: pass - except Exception as e: + except OSError as e: 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) # For now, we periodically re-broadcast cached printer SSDP @@ -425,11 +427,11 @@ class SSDPProxy: await asyncio.sleep(0.1) except OSError as e: - logger.error(f"SSDP proxy error: {e}") + logger.error("SSDP proxy error: %s", e) except asyncio.CancelledError: logger.debug("SSDP proxy cancelled") except Exception as e: - logger.error(f"SSDP proxy error: {e}") + logger.error("SSDP proxy error: %s", e) finally: await self._cleanup() @@ -445,7 +447,7 @@ class SSDPProxy: if sock: try: sock.close() - except Exception: + except OSError: pass self._local_socket = None self._remote_socket = None @@ -470,7 +472,7 @@ class SSDPProxy: headers = self._parse_ssdp_message(data) if 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 self._last_printer_ssdp = data @@ -490,6 +492,6 @@ class SSDPProxy: self._remote_socket.sendto(rewritten, (SSDP_BROADCAST_ADDR, SSDP_PORT)) 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) diff --git a/backend/app/services/virtual_printer/tcp_proxy.py b/backend/app/services/virtual_printer/tcp_proxy.py index 536056244..25fa7be39 100644 --- a/backend/app/services/virtual_printer/tcp_proxy.py +++ b/backend/app/services/virtual_printer/tcp_proxy.py @@ -107,26 +107,26 @@ class TLSProxy: 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: await self._server.serve_forever() except OSError as e: 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: - logger.error(f"{self.name} proxy error: {e}") + logger.error("%s proxy error: %s", self.name, e) except asyncio.CancelledError: - logger.debug(f"{self.name} proxy task cancelled") + logger.debug("%s proxy task cancelled", self.name) except Exception as e: - logger.error(f"{self.name} proxy error: {e}") + logger.error("%s proxy error: %s", self.name, e) finally: await self.stop() async def stop(self) -> None: """Stop the TLS proxy server.""" - logger.info(f"Stopping {self.name} proxy") + logger.info("Stopping %s proxy", self.name) self._running = False # Cancel all active connection tasks @@ -145,8 +145,8 @@ class TLSProxy: try: self._server.close() 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 async def _handle_client( @@ -158,7 +158,7 @@ class TLSProxy: peername = client_writer.get_extra_info("peername") 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: try: @@ -176,19 +176,21 @@ class TLSProxy: ), 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: - 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() await client_writer.wait_closed() return 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() await client_writer.wait_closed() 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() await client_writer.wait_closed() return @@ -221,7 +223,7 @@ class TLSProxy: pass except Exception as e: - logger.debug(f"{self.name} proxy connection error: {e}") + logger.debug("%s proxy connection error: %s", self.name, e) finally: # Clean up self._active_connections.pop(client_id, None) @@ -230,10 +232,10 @@ class TLSProxy: try: writer.close() await writer.wait_closed() - except Exception: + except OSError: 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: try: @@ -268,18 +270,18 @@ class TLSProxy: await writer.drain() 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: pass except ConnectionResetError: - logger.debug(f"{self.name} proxy {direction}: connection reset") + logger.debug("%s proxy %s: connection reset", self.name, direction) 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: @@ -320,7 +322,7 @@ class SlicerProxyManager: async def start(self) -> None: """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 self._ftp_proxy = TLSProxy( @@ -350,7 +352,7 @@ class SlicerProxyManager: try: await proxy.start() 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 = [ 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) # This keeps the start() coroutine alive so the parent task doesn't complete diff --git a/backend/app/utils/threemf_tools.py b/backend/app/utils/threemf_tools.py index f799b353e..a2de95ae9 100644 --- a/backend/app/utils/threemf_tools.py +++ b/backend/app/utils/threemf_tools.py @@ -12,6 +12,7 @@ import zipfile from pathlib import Path import defusedxml.ElementTree as ET +from defusedxml.ElementTree import ParseError as XMLParseError # Default filament properties 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") return parse_gcode_layer_filament_usage(gcode_content) - except Exception: + except (zipfile.BadZipFile, OSError, UnicodeDecodeError): return None @@ -258,7 +259,7 @@ def extract_filament_properties_from_3mf(file_path: Path) -> dict[int, dict]: properties[fid]["density"] = DEFAULT_FILAMENT_DENSITY except json.JSONDecodeError: pass - except Exception: + except (zipfile.BadZipFile, OSError, KeyError, ValueError, XMLParseError, UnicodeDecodeError): pass return properties @@ -302,7 +303,7 @@ def extract_filament_usage_from_3mf(file_path: Path) -> list[dict]: ) except (ValueError, TypeError): pass - except Exception: + except (zipfile.BadZipFile, OSError, KeyError, ValueError, XMLParseError, UnicodeDecodeError): pass return filament_usage diff --git a/requirements-dev.txt b/requirements-dev.txt index 17d60267a..9bb016d45 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -5,3 +5,7 @@ pytest-cov>=4.1.0 pytest-xdist>=3.5.0 httpx>=0.27.0 ruff>=0.8.0 + +# Security scanning +bandit[sarif]>=1.7.0 +pip-audit>=2.7.0 diff --git a/test_security.sh b/test_security.sh new file mode 100755 index 000000000..7a295b2f9 --- /dev/null +++ b/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