From 4b46e443dc5ba41cf1c36701435df555f26839ba Mon Sep 17 00:00:00 2001 From: maziggy Date: Sat, 7 Feb 2026 09:20:06 +0100 Subject: [PATCH] Fix critical FTP upload failure and revert dangerous exception narrowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CodeQL cleanup in "Housekeeping" (2b11efd) bulk-narrowed except clauses across 50+ files, breaking FTP uploads on ALL printer models. ftplib.error_perm (550 errors) is not a subclass of ftplib.error_reply, so diagnose_storage() CWD failures escaped the handler and prevented STOR from ever executing — causing 100% upload failure and HTTP 500s on /api/v1/archives/{id}/reprint and /api/v1/library/files/{id}/print. FTP fixes: - Remove diagnose_storage() from upload hot path - Change all except (OSError, ftplib.error_reply) to except (OSError, ftplib.Error) across bambu_ftp.py Exception handling reverts (9 files): - Revert narrowed except clauses back to except Exception in route handlers and service code where broad catches are intentional defensive programming (archive parsing, HTTP clients, 3MF/ZIP processing, Home Assistant, firmware checks) - Keep narrow exceptions only where safe (single-op blocks like int(), file.unlink(), socket.close()) - Remove unused XMLParseError imports from archive.py, threemf_tools.py Closes #287 --- DOCKERHUB.md | 113 ++++++++++++++++++ backend/app/api/routes/archives.py | 22 ++-- backend/app/api/routes/library.py | 22 ++-- backend/app/api/routes/print_queue.py | 4 +- backend/app/api/routes/printers.py | 2 +- backend/app/services/archive.py | 23 ++-- backend/app/services/bambu_ftp.py | 45 +++---- backend/app/services/firmware_check.py | 6 +- backend/app/services/homeassistant.py | 12 +- backend/app/utils/threemf_tools.py | 7 +- docker-publish.sh | 157 +++++++++++++++++++------ 11 files changed, 298 insertions(+), 115 deletions(-) create mode 100644 DOCKERHUB.md diff --git a/DOCKERHUB.md b/DOCKERHUB.md new file mode 100644 index 000000000..bcaef6d61 --- /dev/null +++ b/DOCKERHUB.md @@ -0,0 +1,113 @@ +# Bambuddy + +**Self-hosted print archive and management system for Bambu Lab 3D printers.** + +No cloud dependency. Complete privacy. Full control. + +[![GitHub](https://img.shields.io/github/stars/maziggy/bambuddy?style=flat-square&label=GitHub)](https://github.com/maziggy/bambuddy) +[![License](https://img.shields.io/github/license/maziggy/bambuddy?style=flat-square)](https://github.com/maziggy/bambuddy/blob/main/LICENSE) +[![Discord](https://img.shields.io/discord/1461241694715645994?style=flat-square&logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/aFS3ZfScHM) + +## Quick Start + +```bash +mkdir bambuddy && cd bambuddy +curl -O https://raw.githubusercontent.com/maziggy/bambuddy/main/docker-compose.yml +docker compose up -d +``` + +Open **http://localhost:8000** and add your printer. + +> **Requirements:** Bambu Lab printer with Developer Mode enabled, on the same local network. + +## Supported Architectures + +| Architecture | Tag | +|---|---| +| x86-64 (Intel/AMD) | `amd64` | +| arm64 (Raspberry Pi 4/5) | `arm64` | + +## Features + +- **Real-Time Monitoring** — Live printer status, camera streaming, HMS error tracking (853 codes translated), resizable multi-printer dashboard +- **Print Archive** — Automatic 3MF archiving with metadata, interactive 3D model viewer (Three.js), photo attachments, failure analysis, side-by-side comparison +- **Print Scheduling** — Drag-and-drop queue, multi-printer assignment by model or location, time-based scheduling, re-print with AMS mapping +- **Smart Automation** — Smart plug control (Tasmota, Home Assistant, MQTT), auto power-on/off, energy monitoring, maintenance reminders +- **Proxy Mode** — Print remotely from Bambu Studio/OrcaSlicer without VPN or port forwarding, end-to-end TLS encrypted +- **Notifications** — WhatsApp, Telegram, Discord, Email, Pushover, ntfy with customizable templates and quiet hours +- **Projects** — Group related prints, track parts and plates, bill of materials, cost tracking, export as ZIP/JSON +- **File Manager** — Upload and organize sliced files, folder structure, print directly to any printer +- **Integrations** — Spoolman filament sync, MQTT publishing, Prometheus metrics, Bambu Cloud profiles, REST API, Home Assistant +- **Virtual Printer** — Appears in your slicer via SSDP discovery, multiple operating modes (archive, review, queue, proxy) +- **Security** — Optional authentication with group-based permissions (50+ granular), JWT tokens, API key support + +## Configuration + +| Variable | Default | Description | +|---|---|---| +| `TZ` | `UTC` | Timezone (e.g. `America/New_York`, `Europe/Berlin`) | +| `PORT` | `8000` | Web UI port | +| `PUID` | `1000` | User ID for file permissions | +| `PGID` | `1000` | Group ID for file permissions | +| `DEBUG` | `false` | Enable debug logging | + +## Volumes + +| Path | Purpose | +|---|---| +| `/app/data` | Database, archived prints, thumbnails | +| `/app/logs` | Application logs | + +## Docker Compose + +```yaml +services: + bambuddy: + image: maziggy/bambuddy:latest + container_name: bambuddy + network_mode: host + environment: + - TZ=America/New_York + - PUID=1000 + - PGID=1000 + volumes: + - bambuddy_data:/app/data + - bambuddy_logs:/app/logs + restart: unless-stopped + +volumes: + bambuddy_data: + bambuddy_logs: +``` + +> **macOS/Windows:** Docker Desktop doesn't support `network_mode: host`. Replace it with `ports: ["8000:8000"]` and add printers manually by IP. + +## Updating + +```bash +docker compose pull && docker compose up -d +``` + +## Supported Printers + +| Series | Models | Status | +|---|---|---| +| H2 | H2D | Tested | +| X1 | X1 Carbon, X1E | Tested | +| P1 | P1P, P1S | Compatible | +| P2 | P2S | Compatible | +| A1 | A1, A1 Mini | Compatible | + +All printers require **Developer Mode** enabled for LAN access. + +## Links + +- **Website:** [bambuddy.cool](https://bambuddy.cool) +- **Documentation:** [wiki.bambuddy.cool](http://wiki.bambuddy.cool) +- **GitHub:** [github.com/maziggy/bambuddy](https://github.com/maziggy/bambuddy) +- **Discord:** [discord.gg/aFS3ZfScHM](https://discord.gg/aFS3ZfScHM) +- **Issues:** [GitHub Issues](https://github.com/maziggy/bambuddy/issues) + +## License + +MIT License - see [LICENSE](https://github.com/maziggy/bambuddy/blob/main/LICENSE) for details. diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index c1568985a..3201891da 100644 --- a/backend/app/api/routes/archives.py +++ b/backend/app/api/routes/archives.py @@ -1886,7 +1886,7 @@ async def get_archive_capabilities( if " bytes | None: thumbnail_lines.append(data_line) return thumbnail_data - except OSError as e: + except Exception as e: logger.warning("Failed to extract gcode thumbnail: %s", e) return None @@ -774,7 +774,7 @@ async def upload_file( return obj metadata = clean_metadata(raw_metadata) - except (KeyError, ValueError, zipfile.BadZipFile, OSError) as e: + except Exception as e: logger.warning("Failed to parse 3MF: %s", e) elif ext == ".gcode": @@ -787,7 +787,7 @@ async def upload_file( with open(thumb_path, "wb") as f: f.write(thumbnail_data) thumbnail_path = str(thumb_path) - except OSError as e: + except Exception as e: logger.warning("Failed to extract gcode thumbnail: %s", e) elif ext.lower() in IMAGE_EXTENSIONS: @@ -867,7 +867,7 @@ async def extract_zip_file( content = await file.read() tmp.write(content) tmp_path = tmp.name - except OSError as e: + except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to save ZIP file: {str(e)}") extracted_files: list[ZipExtractResult] = [] @@ -1013,7 +1013,7 @@ async def extract_zip_file( return obj metadata = clean_metadata(raw_metadata) - except (KeyError, ValueError, zipfile.BadZipFile, OSError) as e: + except Exception as e: logger.warning("Failed to parse 3MF from ZIP: %s", e) elif ext == ".gcode": @@ -1025,7 +1025,7 @@ async def extract_zip_file( with open(thumb_path, "wb") as f: f.write(thumbnail_data) thumbnail_path = str(thumb_path) - except OSError as e: + except Exception as e: logger.warning("Failed to extract gcode thumbnail from ZIP: %s", e) elif ext.lower() in IMAGE_EXTENSIONS: @@ -1423,7 +1423,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 (KeyError, ValueError, ET.ParseError, UnicodeDecodeError): + except Exception: pass # model_settings.config is optional; skip if missing or malformed # Parse slice_info.config for plate metadata @@ -1516,7 +1516,7 @@ async def get_library_file_plates( names.append(obj_name) if names: plate_json_objects[plate_index] = names - except (json.JSONDecodeError, KeyError, ValueError, UnicodeDecodeError): + except Exception: continue # Build plate list @@ -1553,7 +1553,7 @@ async def get_library_file_plates( } ) - except (KeyError, ValueError, zipfile.BadZipFile, ET.ParseError, UnicodeDecodeError) as e: + except Exception as e: logger.warning("Failed to parse plates from library file %s: %s", file_id, e) return { @@ -1589,7 +1589,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 (zipfile.BadZipFile, KeyError, OSError): + except Exception: pass # Archive unreadable or thumbnail missing; fall through to 404 raise HTTPException(status_code=404, detail=f"Thumbnail for plate {plate_index} not found") @@ -1711,7 +1711,7 @@ async def get_library_file_filament_requirements( # Sort by slot ID filaments.sort(key=lambda x: x["slot_id"]) - except (KeyError, ValueError, zipfile.BadZipFile, ET.ParseError, UnicodeDecodeError) as e: + except Exception as e: logger.warning("Failed to parse filament requirements from library file %s: %s", file_id, e) return { diff --git a/backend/app/api/routes/print_queue.py b/backend/app/api/routes/print_queue.py index 49a99b214..e0905df40 100644 --- a/backend/app/api/routes/print_queue.py +++ b/backend/app/api/routes/print_queue.py @@ -92,7 +92,7 @@ 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 (zipfile.BadZipFile, ET.ParseError, OSError, KeyError, ValueError, UnicodeDecodeError) as e: + except Exception as e: logger.warning("Failed to extract filament types from %s: %s", file_path, e) return sorted(types) @@ -144,7 +144,7 @@ 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 (zipfile.BadZipFile, ET.ParseError, OSError, KeyError, ValueError, UnicodeDecodeError) as e: + except Exception as e: logger.warning("Failed to extract print time from %s: %s", file_path, e) return None diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index f930fd55f..18bdf1c76 100644 --- a/backend/app/api/routes/printers.py +++ b/backend/app/api/routes/printers.py @@ -1113,7 +1113,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 (zipfile.BadZipFile, KeyError, OSError): + except Exception: pass # Corrupt or unreadable 3MF; fall through to 404 raise HTTPException(status_code=404, detail=f"Thumbnail for plate {plate_index} not found") diff --git a/backend/app/services/archive.py b/backend/app/services/archive.py index 5db20c960..599041262 100644 --- a/backend/app/services/archive.py +++ b/backend/app/services/archive.py @@ -8,7 +8,6 @@ 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 @@ -57,7 +56,7 @@ class ThreeMFParser: self.metadata.pop("_slice_filament_type", None) self.metadata.pop("_slice_filament_color", None) self.metadata.pop("_plate_index", None) - except (KeyError, ValueError, zipfile.BadZipFile, XMLParseError, UnicodeDecodeError): + except Exception: pass # Return whatever metadata was extracted before the error return self.metadata @@ -152,7 +151,7 @@ class ThreeMFParser: self.metadata["_slice_filament_type"] = ", ".join(types) if colors: self.metadata["_slice_filament_color"] = ",".join(colors) - except (KeyError, ValueError, XMLParseError, UnicodeDecodeError): + except Exception: pass # Skip unparseable slice_info metadata def _parse_project_settings(self, zf: zipfile.ZipFile): @@ -166,7 +165,7 @@ class ThreeMFParser: self._extract_print_settings(data) except json.JSONDecodeError: pass # Skip malformed project_settings JSON - except (KeyError, ValueError, UnicodeDecodeError): + except Exception: pass # Skip unreadable project settings file def _parse_gcode_header(self, zf: zipfile.ZipFile): @@ -196,7 +195,7 @@ class ThreeMFParser: raw_model = match.group(1).strip() self.metadata["sliced_for_model"] = normalize_printer_model(raw_model) - except (KeyError, ValueError, UnicodeDecodeError): + except Exception: pass # G-code header parsing is best-effort; metadata may come from other sources def _extract_filament_info(self, data: dict): @@ -237,7 +236,7 @@ class ThreeMFParser: if non_support_colors: self.metadata["filament_color"] = ",".join(non_support_colors) - except (KeyError, ValueError, TypeError, IndexError): + except Exception: pass # Filament info is optional; fall back to slice_info values def _extract_print_settings(self, data: dict): @@ -284,7 +283,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 (KeyError, ValueError, TypeError): + except Exception: pass # Print settings are optional; missing values are left unset def _extract_settings_from_content(self, content: str): @@ -353,7 +352,7 @@ class ThreeMFParser: if "Title" in makerworld_fields: self.metadata["print_name"] = makerworld_fields["Title"] - except (KeyError, ValueError, UnicodeDecodeError): + except Exception: pass # MakerWorld/3dmodel metadata is optional def _extract_thumbnail(self, zf: zipfile.ZipFile): @@ -478,7 +477,7 @@ def extract_printable_objects_from_3mf( except ValueError: pass # Skip objects with non-numeric identify_id - except (KeyError, ValueError, zipfile.BadZipFile, XMLParseError, UnicodeDecodeError): + except Exception: pass # Return empty dict if 3MF is corrupt or unreadable if include_positions: @@ -598,7 +597,7 @@ class ProjectPageParser: } ) - except (KeyError, ValueError, zipfile.BadZipFile, UnicodeDecodeError) as e: + except Exception as e: result["_error"] = str(e) return result @@ -623,7 +622,7 @@ class ProjectPageParser: } content_type = content_types.get(ext, "application/octet-stream") return (data, content_type) - except (KeyError, zipfile.BadZipFile, OSError): + except Exception: pass # Return None if image cannot be extracted from 3MF return None @@ -684,7 +683,7 @@ class ProjectPageParser: shutil.move(tmp_path, self.file_path) return True - except (zipfile.BadZipFile, OSError, UnicodeDecodeError, KeyError, ValueError): + except Exception: # Clean up temp file if it exists if "tmp_path" in locals() and tmp_path.exists(): tmp_path.unlink() diff --git a/backend/app/services/bambu_ftp.py b/backend/app/services/bambu_ftp.py index 8965bc15c..abc8ca343 100644 --- a/backend/app/services/bambu_ftp.py +++ b/backend/app/services/bambu_ftp.py @@ -171,7 +171,7 @@ class BambuFTPClient: logger.warning("FTP SSL error connecting to %s: %s", self.ip_address, e) self._ftp = None return False - except (OSError, ftplib.error_reply) as e: + except (OSError, ftplib.Error) 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 (OSError, ftplib.error_reply): + except (OSError, ftplib.Error): pass # Best-effort FTP cleanup; connection may already be closed self._ftp = None @@ -239,7 +239,7 @@ class BambuFTPClient: file_entry["mtime"] = mtime files.append(file_entry) logger.debug("Listed %s files in %s", len(files), path) - except (OSError, ftplib.error_reply) as e: + except (OSError, ftplib.Error) 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 (OSError, ftplib.error_reply): + except (OSError, ftplib.Error): return None def download_to_file(self, remote_path: str, local_path: Path) -> bool: @@ -271,7 +271,7 @@ class BambuFTPClient: file_size = local_path.stat().st_size if local_path.exists() else 0 logger.info("Successfully downloaded %s to %s (%s bytes)", remote_path, local_path, file_size) return True - except (OSError, ftplib.error_reply) as e: + except (OSError, ftplib.Error) as e: # Log at INFO level so we can see failures in normal logs logger.info("FTP download failed for %s: %s", remote_path, e) # Clean up partial file if it exists @@ -302,7 +302,7 @@ class BambuFTPClient: try: results["pwd"] = self._ftp.pwd() logger.debug("FTP current directory: %s", results["pwd"]) - except (OSError, ftplib.error_reply) as e: + except (OSError, ftplib.Error) as e: results["errors"].append(f"PWD failed: {e}") logger.debug("FTP PWD failed: %s", e) @@ -314,7 +314,7 @@ class BambuFTPClient: results["can_list_root"] = True results["root_files"] = items[:10] # First 10 entries logger.debug("FTP root listing (%s items): %s", len(items), items[:5]) - except (OSError, ftplib.error_reply) as e: + except (OSError, ftplib.Error) as e: results["errors"].append(f"LIST / failed: {e}") logger.debug("FTP LIST / failed: %s", e) @@ -325,7 +325,7 @@ class BambuFTPClient: self._ftp.retrlines("LIST", items.append) results["can_list_cache"] = True logger.debug("FTP /cache listing: %s items", len(items)) - except (OSError, ftplib.error_reply) as e: + except (OSError, ftplib.Error) as e: results["errors"].append(f"LIST /cache failed: {e}") logger.debug("FTP LIST /cache failed: %s", e) @@ -333,7 +333,7 @@ class BambuFTPClient: try: results["storage_info"] = self.get_storage_info() logger.debug("FTP storage info: %s", results["storage_info"]) - except (OSError, ftplib.error_reply) as e: + except (OSError, ftplib.Error) as e: results["errors"].append(f"Storage info failed: {e}") return results @@ -353,17 +353,6 @@ class BambuFTPClient: file_size = local_path.stat().st_size if local_path.exists() else 0 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...") - diag = self.diagnose_storage() - logger.info( - f"FTP storage diagnostics: can_list_root={diag['can_list_root']}, " - f"can_list_cache={diag['can_list_cache']}, " - f"storage={diag['storage_info']}, errors={diag['errors']}" - ) - if diag["root_files"]: - logger.debug("FTP root directory contents: %s", diag["root_files"]) - uploaded = 0 # Use manual transfer instead of storbinary() for A1 compatibility @@ -414,7 +403,7 @@ class BambuFTPClient: elif error_code == "552": logger.error("FTP 552 error - Storage quota exceeded (SD card full?)") return False - except (OSError, ftplib.error_reply) as e: + except (OSError, ftplib.Error) as e: logger.error("FTP upload failed for %s: %s (type: %s)", remote_path, e, type(e).__name__) return False @@ -443,7 +432,7 @@ class BambuFTPClient: conn.close() return True - except (OSError, ftplib.error_reply): + except (OSError, ftplib.Error): return False def delete_file(self, remote_path: str) -> bool: @@ -454,7 +443,7 @@ class BambuFTPClient: try: self._ftp.delete(remote_path) return True - except (OSError, ftplib.error_reply) as e: + except (OSError, ftplib.Error) as e: logger.warning("Failed to delete %s: %s", remote_path, e) return False @@ -465,7 +454,7 @@ class BambuFTPClient: try: return self._ftp.size(remote_path) - except (OSError, ftplib.error_reply): + except (OSError, ftplib.Error): return None def get_storage_info(self) -> dict | None: @@ -484,13 +473,13 @@ class BambuFTPClient: parts = response.split() if len(parts) >= 2: result["free_bytes"] = int(parts[1]) - except (OSError, ftplib.error_reply) as e: + except (OSError, ftplib.Error) as e: logger.debug("AVBL command not supported: %s", e) # Try STAT command as fallback try: response = self._ftp.sendcmd("STAT") logger.debug("STAT response: %s", response) - except (OSError, ftplib.error_reply): + except (OSError, ftplib.Error): pass # Both AVBL and STAT unsupported; storage info will rely on directory scan # Calculate used space by listing root directories @@ -511,11 +500,11 @@ class BambuFTPClient: total_used += int(parts[4]) except ValueError: pass # Skip entries with non-numeric size fields - except (OSError, ftplib.error_reply): + except (OSError, ftplib.Error): pass # Directory may not exist on this printer model; skip it result["used_bytes"] = total_used - except (OSError, ftplib.error_reply): + except (OSError, ftplib.Error): pass # Storage scan failed; return whatever info was collected above return result if result else None diff --git a/backend/app/services/firmware_check.py b/backend/app/services/firmware_check.py index c2bcb00e0..e0ec800cf 100644 --- a/backend/app/services/firmware_check.py +++ b/backend/app/services/firmware_check.py @@ -103,7 +103,7 @@ class FirmwareCheckService: logger.info("Got Bambu Lab build ID: %s", self._build_id) return self._build_id logger.warning("Failed to get Bambu Lab page: %s", response.status_code) - except (httpx.HTTPError, OSError) as e: + except Exception as e: logger.error("Error fetching Bambu Lab build ID: %s", e) return self._build_id # Return cached value if available @@ -138,7 +138,7 @@ class FirmwareCheckService: # api_key is a printer model identifier (e.g. "x1", "p1"), not a secret logger.warning("Failed to fetch firmware for %s: %s", api_key, response.status_code) - except (httpx.HTTPError, OSError, KeyError, ValueError) as e: + except Exception as e: # api_key is a printer model identifier (e.g. "x1", "p1"), not a secret logger.error("Error fetching firmware for %s: %s", api_key, e) @@ -359,7 +359,7 @@ class FirmwareCheckService: return original_path - except (httpx.HTTPError, OSError) as e: + except Exception as e: logger.error("Firmware download failed: %s", e) if temp_path.exists(): try: diff --git a/backend/app/services/homeassistant.py b/backend/app/services/homeassistant.py index b50fb9eeb..4f7065ffd 100644 --- a/backend/app/services/homeassistant.py +++ b/backend/app/services/homeassistant.py @@ -65,7 +65,7 @@ class HomeAssistantService: "reachable": True, "device_name": data.get("attributes", {}).get("friendly_name"), } - except (httpx.HTTPError, OSError, KeyError) as e: + except Exception 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} @@ -106,7 +106,7 @@ class HomeAssistantService: ) response.raise_for_status() return True - except (httpx.HTTPError, OSError) as e: + except Exception as e: logger.warning("Failed to %s HA entity %s: %s", action, plug.ha_entity_id, e) return False @@ -166,7 +166,7 @@ class HomeAssistantService: "apparent_power": None, "reactive_power": None, } - except (httpx.HTTPError, OSError, KeyError, ValueError) as e: + except Exception as e: logger.debug("Failed to get HA energy data: %s", e) return None @@ -181,7 +181,7 @@ class HomeAssistantService: state = response.json().get("state") if state and state not in ("unknown", "unavailable"): return float(state) - except (httpx.HTTPError, OSError, ValueError): + except Exception: pass # Sensor read is best-effort; caller handles None return None @@ -284,7 +284,7 @@ class HomeAssistantService: ) return sorted(entities, key=lambda x: x["friendly_name"].lower()) - except (httpx.HTTPError, OSError, KeyError) as e: + except Exception as e: logger.warning("Failed to list HA entities: %s", e) return [] @@ -330,7 +330,7 @@ class HomeAssistantService: ) return sorted(entities, key=lambda x: x["friendly_name"].lower()) - except (httpx.HTTPError, OSError, KeyError) as e: + except Exception as e: logger.warning("Failed to list HA sensor entities: %s", e) return [] diff --git a/backend/app/utils/threemf_tools.py b/backend/app/utils/threemf_tools.py index 95c605bca..e2c6f019b 100644 --- a/backend/app/utils/threemf_tools.py +++ b/backend/app/utils/threemf_tools.py @@ -12,7 +12,6 @@ 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 @@ -177,7 +176,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 (zipfile.BadZipFile, OSError, UnicodeDecodeError): + except Exception: return None @@ -259,7 +258,7 @@ def extract_filament_properties_from_3mf(file_path: Path) -> dict[int, dict]: properties[fid]["density"] = DEFAULT_FILAMENT_DENSITY except json.JSONDecodeError: pass # Skip malformed project_settings.config JSON - except (zipfile.BadZipFile, OSError, KeyError, ValueError, XMLParseError, UnicodeDecodeError): + except Exception: pass # Return whatever properties were collected before the error return properties @@ -303,7 +302,7 @@ def extract_filament_usage_from_3mf(file_path: Path) -> list[dict]: ) except (ValueError, TypeError): pass # Skip filament entries with unparseable usage values - except (zipfile.BadZipFile, OSError, KeyError, ValueError, XMLParseError, UnicodeDecodeError): + except Exception: pass # Return whatever usage data was collected before the error return filament_usage diff --git a/docker-publish.sh b/docker-publish.sh index cd5937a9a..498ed669b 100755 --- a/docker-publish.sh +++ b/docker-publish.sh @@ -1,20 +1,25 @@ #!/bin/bash -# Build and push multi-architecture Docker image to GitHub Container Registry +# Build and push multi-architecture Docker image to GitHub Container Registry AND Docker Hub # # Usage: -# ./docker-publish.sh [version] [--parallel] +# ./docker-publish.sh [version] [--parallel] [--ghcr-only] [--dockerhub-only] # # Examples: -# ./docker-publish.sh 0.1.7b # Sequential build (default) -# ./docker-publish.sh 0.1.7b --parallel # Build both archs simultaneously +# ./docker-publish.sh 0.1.9b # Sequential build, push to both registries +# ./docker-publish.sh 0.1.9b --parallel # Build both archs simultaneously +# ./docker-publish.sh 0.1.9b --ghcr-only # Only push to GHCR +# ./docker-publish.sh 0.1.9b --dockerhub-only # Only push to Docker Hub # # Note: All versions are also tagged as 'latest' # # Prerequisites: -# 1. Log in to ghcr.io first: +# 1. Log in to ghcr.io: # echo $GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin # -# 2. Create a GitHub Personal Access Token with 'write:packages' scope: +# 2. Log in to Docker Hub: +# docker login -u YOUR_USERNAME +# +# 3. Create a GitHub Personal Access Token with 'write:packages' scope: # https://github.com/settings/tokens/new?scopes=write:packages # # Supported architectures: @@ -24,9 +29,11 @@ set -e # Configuration -REGISTRY="ghcr.io" +GHCR_REGISTRY="ghcr.io" +DOCKERHUB_REGISTRY="docker.io" IMAGE_NAME="maziggy/bambuddy" -FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}" +GHCR_IMAGE="${GHCR_REGISTRY}/${IMAGE_NAME}" +DOCKERHUB_IMAGE="${DOCKERHUB_REGISTRY}/${IMAGE_NAME}" PLATFORMS="linux/amd64,linux/arm64" BUILDER_NAME="bambuddy-builder" @@ -40,11 +47,19 @@ NC='\033[0m' # No Color # Parse arguments VERSION="" PARALLEL=false +PUSH_GHCR=true +PUSH_DOCKERHUB=true for arg in "$@"; do case $arg in --parallel) PARALLEL=true ;; + --ghcr-only) + PUSH_DOCKERHUB=false + ;; + --dockerhub-only) + PUSH_GHCR=false + ;; *) if [ -z "$VERSION" ]; then VERSION="$arg" @@ -54,9 +69,11 @@ for arg in "$@"; do done if [ -z "$VERSION" ]; then - echo -e "${YELLOW}Usage: $0 [--parallel]${NC}" - echo "Example: $0 0.1.6" - echo " $0 0.1.6 --parallel # Build both architectures simultaneously" + echo -e "${YELLOW}Usage: $0 [--parallel] [--ghcr-only] [--dockerhub-only]${NC}" + echo "Example: $0 0.1.9b" + echo " $0 0.1.9b --parallel # Build both architectures simultaneously" + echo " $0 0.1.9b --ghcr-only # Only push to GitHub Container Registry" + echo " $0 0.1.9b --dockerhub-only # Only push to Docker Hub" exit 1 fi @@ -65,7 +82,7 @@ CPU_COUNT=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4) echo -e "${GREEN}================================================${NC}" echo -e "${GREEN} Building multi-arch image${NC}" -echo -e "${GREEN} ${FULL_IMAGE}:${VERSION}${NC}" +echo -e "${GREEN} Version: ${VERSION}${NC}" echo -e "${GREEN} Platforms: ${PLATFORMS}${NC}" echo -e "${GREEN} CPU cores: ${CPU_COUNT}${NC}" if [ "$PARALLEL" = true ]; then @@ -73,17 +90,33 @@ if [ "$PARALLEL" = true ]; then else echo -e "${GREEN} Mode: Sequential (amd64 → arm64)${NC}" fi +echo -e "${GREEN} Registries:${NC}" +if [ "$PUSH_GHCR" = true ]; then + echo -e "${GREEN} - ${GHCR_IMAGE}${NC}" +fi +if [ "$PUSH_DOCKERHUB" = true ]; then + echo -e "${GREEN} - ${DOCKERHUB_IMAGE}${NC}" +fi echo -e "${GREEN}================================================${NC}" echo "" -# Check if logged in to ghcr.io -if ! grep -q "ghcr.io" ~/.docker/config.json 2>/dev/null; then - echo -e "${YELLOW}Warning: You may not be logged in to ghcr.io${NC}" - echo "Run: echo \$GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin" - echo "" +# Check registry logins +if [ "$PUSH_GHCR" = true ]; then + if ! grep -q "ghcr.io" ~/.docker/config.json 2>/dev/null; then + echo -e "${YELLOW}Warning: You may not be logged in to ghcr.io${NC}" + echo "Run: echo \$GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin" + echo "" + fi fi -# Always tag as latest (in addition to version tag) +if [ "$PUSH_DOCKERHUB" = true ]; then + if ! grep -q "index.docker.io\|docker.io" ~/.docker/config.json 2>/dev/null; then + echo -e "${RED}Error: You are not logged in to Docker Hub${NC}" + echo "Run: docker login -u YOUR_USERNAME" + echo "" + exit 1 + fi +fi # Setup buildx builder if not exists echo -e "${BLUE}[1/4] Setting up Docker Buildx...${NC}" @@ -110,23 +143,42 @@ if ! docker buildx inspect --bootstrap | grep -q "linux/arm64"; then docker run --privileged --rm tonistiigi/binfmt --install all fi -# Build tags (always include latest) -TAGS="-t ${FULL_IMAGE}:${VERSION} -t ${FULL_IMAGE}:latest" -echo -e "${BLUE}[3/4] Building and pushing (version + latest)...${NC}" +# Build tags for all target registries +TAGS="" +if [ "$PUSH_GHCR" = true ]; then + TAGS="$TAGS -t ${GHCR_IMAGE}:${VERSION} -t ${GHCR_IMAGE}:latest" +fi +if [ "$PUSH_DOCKERHUB" = true ]; then + TAGS="$TAGS -t ${DOCKERHUB_IMAGE}:${VERSION} -t ${DOCKERHUB_IMAGE}:latest" +fi + +echo -e "${BLUE}[3/4] Building and pushing...${NC}" # Common build args (no cache to ensure clean builds) BUILD_ARGS="--provenance=false --sbom=false --no-cache --pull" if [ "$PARALLEL" = true ]; then - # Parallel build: Build each architecture separately then combine + # Parallel build: Build each architecture separately then combine manifests echo -e "${YELLOW}Building amd64 and arm64 in parallel (${CPU_COUNT} cores each, no cache)...${NC}" + # Build per-arch staging tags for each target registry + ARCH_TAGS_AMD64="" + ARCH_TAGS_ARM64="" + if [ "$PUSH_GHCR" = true ]; then + ARCH_TAGS_AMD64="$ARCH_TAGS_AMD64 -t ${GHCR_IMAGE}:${VERSION}-amd64" + ARCH_TAGS_ARM64="$ARCH_TAGS_ARM64 -t ${GHCR_IMAGE}:${VERSION}-arm64" + fi + if [ "$PUSH_DOCKERHUB" = true ]; then + ARCH_TAGS_AMD64="$ARCH_TAGS_AMD64 -t ${DOCKERHUB_IMAGE}:${VERSION}-amd64" + ARCH_TAGS_ARM64="$ARCH_TAGS_ARM64 -t ${DOCKERHUB_IMAGE}:${VERSION}-arm64" + fi + # Build amd64 in background ( echo -e "${BLUE}[amd64] Starting build...${NC}" docker buildx build \ --platform linux/amd64 \ - -t "${FULL_IMAGE}:${VERSION}-amd64" \ + ${ARCH_TAGS_AMD64} \ ${BUILD_ARGS} \ --push \ . 2>&1 | sed 's/^/[amd64] /' @@ -139,7 +191,7 @@ if [ "$PARALLEL" = true ]; then echo -e "${BLUE}[arm64] Starting build...${NC}" docker buildx build \ --platform linux/arm64 \ - -t "${FULL_IMAGE}:${VERSION}-arm64" \ + ${ARCH_TAGS_ARM64} \ ${BUILD_ARGS} \ --push \ . 2>&1 | sed 's/^/[arm64] /' @@ -152,13 +204,23 @@ if [ "$PARALLEL" = true ]; then wait $PID_AMD64 wait $PID_ARM64 - # Create and push multi-arch manifest (version + latest) - echo -e "${BLUE}Creating multi-arch manifest...${NC}" - docker buildx imagetools create \ - -t "${FULL_IMAGE}:${VERSION}" \ - -t "${FULL_IMAGE}:latest" \ - "${FULL_IMAGE}:${VERSION}-amd64" \ - "${FULL_IMAGE}:${VERSION}-arm64" + # Create multi-arch manifests per registry (no cross-registry blob copies) + echo -e "${BLUE}Creating multi-arch manifests...${NC}" + + if [ "$PUSH_GHCR" = true ]; then + echo -e "${BLUE} Creating GHCR manifest...${NC}" + docker buildx imagetools create \ + -t "${GHCR_IMAGE}:${VERSION}" -t "${GHCR_IMAGE}:latest" \ + "${GHCR_IMAGE}:${VERSION}-amd64" \ + "${GHCR_IMAGE}:${VERSION}-arm64" + fi + if [ "$PUSH_DOCKERHUB" = true ]; then + echo -e "${BLUE} Creating Docker Hub manifest...${NC}" + docker buildx imagetools create \ + -t "${DOCKERHUB_IMAGE}:${VERSION}" -t "${DOCKERHUB_IMAGE}:latest" \ + "${DOCKERHUB_IMAGE}:${VERSION}-amd64" \ + "${DOCKERHUB_IMAGE}:${VERSION}-arm64" + fi else # Sequential build (default): Build both platforms in one command echo -e "${YELLOW}Building sequentially with ${CPU_COUNT} cores (no cache)...${NC}" @@ -170,19 +232,40 @@ else . fi -echo -e "${BLUE}[4/4] Verifying manifest...${NC}" -docker buildx imagetools inspect "${FULL_IMAGE}:${VERSION}" +echo -e "${BLUE}[4/4] Verifying manifests...${NC}" +if [ "$PUSH_GHCR" = true ]; then + echo -e "${BLUE}GHCR:${NC}" + docker buildx imagetools inspect "${GHCR_IMAGE}:${VERSION}" +fi +if [ "$PUSH_DOCKERHUB" = true ]; then + echo -e "${BLUE}Docker Hub:${NC}" + docker buildx imagetools inspect "${DOCKERHUB_IMAGE}:${VERSION}" +fi echo "" echo -e "${GREEN}================================================${NC}" -echo -e "${GREEN}✓ Successfully pushed multi-arch image:${NC}" +echo -e "${GREEN} Successfully pushed multi-arch image:${NC}" echo -e "${GREEN}================================================${NC}" -echo " - ${FULL_IMAGE}:${VERSION}" -echo " - ${FULL_IMAGE}:latest" +if [ "$PUSH_GHCR" = true ]; then + echo " GHCR:" + echo " - ${GHCR_IMAGE}:${VERSION}" + echo " - ${GHCR_IMAGE}:latest" +fi +if [ "$PUSH_DOCKERHUB" = true ]; then + echo " Docker Hub:" + echo " - ${DOCKERHUB_IMAGE}:${VERSION}" + echo " - ${DOCKERHUB_IMAGE}:latest" +fi echo "" echo -e "${BLUE}Supported platforms:${NC}" echo " - linux/amd64 (Intel/AMD servers, desktops)" echo " - linux/arm64 (Raspberry Pi 4/5, Apple Silicon)" echo "" echo -e "${GREEN}Users can now run:${NC}" -echo " docker pull ${FULL_IMAGE}:${VERSION}" +if [ "$PUSH_GHCR" = true ]; then + echo " docker pull ${GHCR_IMAGE}:${VERSION}" +fi +if [ "$PUSH_DOCKERHUB" = true ]; then + echo " docker pull ${DOCKERHUB_IMAGE}:${VERSION}" + echo " docker pull ${IMAGE_NAME}:${VERSION} # shorthand" +fi