Fix critical FTP upload failure and revert dangerous exception narrowing

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
This commit is contained in:
maziggy
2026-02-07 09:20:06 +01:00
parent 8e9d252ad3
commit 4b46e443dc
11 changed files with 298 additions and 115 deletions
+113
View File
@@ -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.
+11 -11
View File
@@ -1886,7 +1886,7 @@ async def get_archive_capabilities(
if "<vertex" in content or "<mesh" in content:
found_mesh = True
break
except (KeyError, UnicodeDecodeError):
except Exception:
pass # Skip unreadable .model entries in archive
# Extract filament colors from project_settings.config
@@ -1928,7 +1928,7 @@ async def get_archive_capabilities(
for color in raw_colors:
if color and isinstance(color, str):
colors.append(color)
except (json.JSONDecodeError, KeyError, ValueError, TypeError):
except Exception:
pass # Skip malformed project_settings.config
except zipfile.BadZipFile:
pass # File is not a valid zip/3MF archive
@@ -1961,7 +1961,7 @@ async def get_archive_capabilities(
if "<vertex" in content or "<mesh" in content:
has_model = True
break
except (KeyError, UnicodeDecodeError):
except Exception:
pass # Skip unreadable .model entries in archive
# Extract filament colors from slice_info.config (for gcode preview)
@@ -1995,7 +1995,7 @@ async def get_archive_capabilities(
max_tool = max(filament_map.keys())
for i in range(max_tool + 1):
slice_colors.append(filament_map.get(i, "#00AE42"))
except (KeyError, ValueError, ET.ParseError, UnicodeDecodeError):
except Exception:
pass # Skip malformed slice_info.config XML
# Use slice_info colors if we don't have colors from source yet
@@ -2041,7 +2041,7 @@ async def get_archive_capabilities(
for color in raw_colors:
if color and isinstance(color, str):
filament_colors.append(color)
except (json.JSONDecodeError, KeyError, ValueError, TypeError):
except Exception:
pass # Skip malformed project_settings.config
except zipfile.BadZipFile:
@@ -2130,7 +2130,7 @@ async def get_plate_preview(
plate_elem = root.find(".//plate/metadata[@key='index']")
if plate_elem is not None:
plate_num = int(plate_elem.get("value", "1"))
except (KeyError, ValueError, ET.ParseError, UnicodeDecodeError):
except Exception:
pass # Default plate_num=1 if slice_info is missing or malformed
# Try plate-specific image first, then fall back to plate_1
@@ -2376,7 +2376,7 @@ async def get_archive_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 parsing is optional
# Parse slice_info.config for plate metadata
@@ -2475,7 +2475,7 @@ async def get_archive_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
@@ -2512,7 +2512,7 @@ async def get_archive_plates(
}
)
except (KeyError, ValueError, zipfile.BadZipFile, ET.ParseError, UnicodeDecodeError) as e:
except Exception as e:
logger.warning("Failed to parse plates from archive %s: %s", archive_id, e)
return {
@@ -2548,7 +2548,7 @@ async def get_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 # Fall through to 404 if archive is unreadable or thumbnail missing
raise HTTPException(404, f"Thumbnail for plate {plate_index} not found")
@@ -2664,7 +2664,7 @@ async def get_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 archive %s: %s", archive_id, e)
return {
+11 -11
View File
@@ -175,7 +175,7 @@ def extract_gcode_thumbnail(file_path: Path) -> 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 {
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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")
+11 -12
View File
@@ -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()
+17 -28
View File
@@ -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
+3 -3
View File
@@ -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:
+6 -6
View File
@@ -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 []
+3 -4
View File
@@ -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
+120 -37
View File
@@ -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 <version> [--parallel]${NC}"
echo "Example: $0 0.1.6"
echo " $0 0.1.6 --parallel # Build both architectures simultaneously"
echo -e "${YELLOW}Usage: $0 <version> [--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