Merge pull request #288 from maziggy/0.1.8.1

v0.1.8.1
This commit is contained in:
MartinNYHC
2026-02-07 09:43:20 +01:00
committed by GitHub
19 changed files with 412 additions and 198 deletions
+10
View File
@@ -2,6 +2,16 @@
All notable changes to Bambuddy will be documented in this file.
## [0.1.8.1] - 2026-02-07
### Fixed
- **FTP Upload Broken on All Printer Models** — Fixed critical bug where all FTP uploads failed with "550 Failed to change directory":
- `diagnose_storage()` was running before every upload, and its CWD failures (`ftplib.error_perm`) were not caught because `error_perm` is not a subclass of `error_reply`
- Removed `diagnose_storage()` from the upload hot path
- Changed all FTP exception handlers from `except (OSError, ftplib.error_reply)` to `except (OSError, ftplib.Error)` to catch all FTP error types
- **HTTP 500 on Reprint and Print Endpoints** — Fixed 500 errors on `/api/v1/archives/{id}/reprint` and `/api/v1/library/files/{id}/print` caused by the FTP failure above
- **Exception Handling Reverted** — Reverted overly-narrow exception handling introduced in 0.1.8 that could cause uncaught errors in archive parsing, HTTP clients, 3MF/ZIP processing, Home Assistant, and firmware checks
- **4-Segment Version Support** — Version parser now supports patch releases like `0.1.8.1` for hotfixes without incrementing the minor version
## [0.1.8] - 2026-02-06
+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.
+17 -13
View File
@@ -1186,7 +1186,9 @@ async def scan_timelapse(
files = []
for timelapse_path in ["/timelapse", "/timelapse/video"]:
try:
files = await list_files_async(printer.ip_address, printer.access_code, timelapse_path)
files = await list_files_async(
printer.ip_address, printer.access_code, timelapse_path, printer_model=printer.model
)
if files:
break
except Exception:
@@ -1406,7 +1408,9 @@ async def select_timelapse(
remote_path = None
for timelapse_dir in ["/timelapse", "/timelapse/video"]:
try:
files = await list_files_async(printer.ip_address, printer.access_code, timelapse_dir)
files = await list_files_async(
printer.ip_address, printer.access_code, timelapse_dir, printer_model=printer.model
)
for f in files:
if f.get("name") == filename:
remote_path = f.get("path") or f"{timelapse_dir}/{filename}"
@@ -1882,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
@@ -1924,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
@@ -1957,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)
@@ -1991,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
@@ -2037,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:
@@ -2126,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
@@ -2372,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
@@ -2471,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
@@ -2508,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 {
@@ -2544,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")
@@ -2660,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
+17 -10
View File
@@ -637,6 +637,7 @@ async def get_printer_cover(
printer.access_code,
remote_paths,
temp_path,
printer_model=printer.model,
)
if downloaded:
break
@@ -750,7 +751,7 @@ async def list_printer_files(
if not printer:
raise HTTPException(404, "Printer not found")
files = await list_files_async(printer.ip_address, printer.access_code, path)
files = await list_files_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
# Add full path to each file
for f in files:
@@ -775,7 +776,7 @@ async def download_printer_file(
if not printer:
raise HTTPException(404, "Printer not found")
data = await download_file_bytes_async(printer.ip_address, printer.access_code, path)
data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
if data is None:
raise HTTPException(404, f"File not found: {path}")
@@ -819,7 +820,7 @@ async def get_printer_file_gcode(
if not printer:
raise HTTPException(404, "Printer not found")
data = await download_file_bytes_async(printer.ip_address, printer.access_code, path)
data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
if data is None:
raise HTTPException(404, f"File not found: {path}")
@@ -871,7 +872,7 @@ async def get_printer_file_plates(
"is_multi_plate": False,
}
data = await download_file_bytes_async(printer.ip_address, printer.access_code, path)
data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
if data is None:
raise HTTPException(404, f"File not found: {path}")
@@ -1102,7 +1103,7 @@ async def get_printer_file_plate_thumbnail(
if not printer:
raise HTTPException(404, "Printer not found")
data = await download_file_bytes_async(printer.ip_address, printer.access_code, path)
data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
if data is None:
raise HTTPException(404, f"File not found: {path}")
@@ -1112,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")
@@ -1142,7 +1143,9 @@ async def download_printer_files_as_zip(
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
for path in paths:
try:
data = await download_file_bytes_async(printer.ip_address, printer.access_code, path)
data = await download_file_bytes_async(
printer.ip_address, printer.access_code, path, printer_model=printer.model
)
if data:
filename = path.split("/")[-1]
zf.writestr(filename, data)
@@ -1176,7 +1179,7 @@ async def delete_printer_file(
if not printer:
raise HTTPException(404, "Printer not found")
success = await delete_file_async(printer.ip_address, printer.access_code, path)
success = await delete_file_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
if not success:
raise HTTPException(500, f"Failed to delete file: {path}")
@@ -1195,7 +1198,7 @@ async def get_printer_storage(
if not printer:
raise HTTPException(404, "Printer not found")
storage_info = await get_storage_info_async(printer.ip_address, printer.access_code)
storage_info = await get_storage_info_async(printer.ip_address, printer.access_code, printer_model=printer.model)
return storage_info or {"used_bytes": None, "free_bytes": None}
@@ -1919,7 +1922,11 @@ async def get_printable_objects(
try:
downloaded = await download_file_try_paths_async(
printer.ip_address, printer.access_code, remote_paths, temp_path
printer.ip_address,
printer.access_code,
remote_paths,
temp_path,
printer_model=printer.model,
)
if downloaded and temp_path.exists():
with open(temp_path, "rb") as f:
+18 -16
View File
@@ -71,31 +71,33 @@ def _find_executable(name: str) -> str | None:
def parse_version(version: str) -> tuple:
"""Parse version string into tuple for comparison.
Returns (major, minor, patch, is_prerelease, prerelease_num)
Returns (major, minor, patch, micro, is_prerelease, prerelease_num)
where is_prerelease is 0 for release, 1 for prerelease.
This ensures releases sort higher than prereleases of same version.
Examples:
"0.1.5" -> (0, 1, 5, 0, 0) # release
"0.1.5b7" -> (0, 1, 5, 1, 7) # beta 7
"0.1.5b10" -> (0, 1, 5, 1, 10) # beta 10
"0.1.5" -> (0, 1, 5, 0, 0, 0) # release
"0.1.5b7" -> (0, 1, 5, 0, 1, 7) # beta 7
"0.1.5b10" -> (0, 1, 5, 0, 1, 10) # beta 10
"0.1.8.1" -> (0, 1, 8, 1, 0, 0) # patch release
"""
# Remove 'v' prefix if present
version = version.lstrip("v")
# Match version pattern: major.minor.patch[b|beta|alpha|rc]N
match = re.match(r"(\d+)\.(\d+)\.(\d+)(?:b|beta|alpha|rc)?(\d+)?", version)
# Match version pattern: major.minor.patch[.micro][b|beta|alpha|rc]N
match = re.match(r"(\d+)\.(\d+)\.(\d+)(?:\.(\d+))?(?:b|beta|alpha|rc)?(\d+)?", version)
if match:
major = int(match.group(1))
minor = int(match.group(2))
patch = int(match.group(3))
prerelease_num = int(match.group(4)) if match.group(4) else 0
micro = int(match.group(4)) if match.group(4) else 0
prerelease_num = int(match.group(5)) if match.group(5) else 0
# Check if this is a prerelease (has b/beta/alpha/rc suffix)
is_prerelease = 1 if re.search(r"[a-zA-Z]", version.split(".")[-1]) else 0
return (major, minor, patch, is_prerelease, prerelease_num)
return (major, minor, patch, micro, is_prerelease, prerelease_num)
# Fallback: try simple split
parts = []
@@ -106,7 +108,7 @@ def parse_version(version: str) -> tuple:
num = "".join(c for c in part if c.isdigit())
parts.append(int(num) if num else 0)
return tuple(parts) + (0, 0)
return tuple(parts) + (0, 0, 0)
def is_newer_version(latest: str, current: str) -> bool:
@@ -121,9 +123,9 @@ def is_newer_version(latest: str, current: str) -> bool:
latest_parsed = parse_version(latest)
current_parsed = parse_version(current)
# Compare (major, minor, patch) first
latest_base = latest_parsed[:3]
current_base = current_parsed[:3]
# Compare (major, minor, patch, micro) first
latest_base = latest_parsed[:4]
current_base = current_parsed[:4]
if latest_base > current_base:
return True
@@ -133,8 +135,8 @@ def is_newer_version(latest: str, current: str) -> bool:
# Same base version - compare prerelease status
# is_prerelease: 0 = release, 1 = prerelease
# Release (0) should be "greater" than prerelease (1)
latest_is_prerelease = latest_parsed[3] if len(latest_parsed) > 3 else 0
current_is_prerelease = current_parsed[3] if len(current_parsed) > 3 else 0
latest_is_prerelease = latest_parsed[4] if len(latest_parsed) > 4 else 0
current_is_prerelease = current_parsed[4] if len(current_parsed) > 4 else 0
if latest_is_prerelease < current_is_prerelease:
# latest is release, current is prerelease -> latest is newer
@@ -145,8 +147,8 @@ def is_newer_version(latest: str, current: str) -> bool:
# Both are same type (both release or both prerelease)
# Compare prerelease numbers
latest_prerelease_num = latest_parsed[4] if len(latest_parsed) > 4 else 0
current_prerelease_num = current_parsed[4] if len(current_parsed) > 4 else 0
latest_prerelease_num = latest_parsed[5] if len(latest_parsed) > 5 else 0
current_prerelease_num = current_parsed[5] if len(current_parsed) > 5 else 0
return latest_prerelease_num > current_prerelease_num
+1 -1
View File
@@ -5,7 +5,7 @@ from pathlib import Path
from pydantic_settings import BaseSettings
# Application version - single source of truth
APP_VERSION = "0.1.8"
APP_VERSION = "0.1.8.1"
GITHUB_REPO = "maziggy/bambuddy"
# App directory - where the application is installed (for static files)
+13 -3
View File
@@ -1096,7 +1096,9 @@ async def on_print_start(printer_id: int, data: dict):
if downloaded_filename:
break
try:
dir_files = await list_files_async(printer.ip_address, printer.access_code, search_dir)
dir_files = await list_files_async(
printer.ip_address, printer.access_code, search_dir, printer_model=printer.model
)
threemf_files = [f.get("name") for f in dir_files if f.get("name", "").endswith(".3mf")]
if threemf_files:
logger.info(
@@ -1120,6 +1122,8 @@ async def on_print_start(printer_id: int, data: dict):
printer.access_code,
f"{search_dir}/{fname}",
temp_path,
socket_timeout=ftp_timeout,
printer_model=printer.model,
max_retries=ftp_retry_count,
retry_delay=ftp_retry_delay,
operation_name=f"Download 3MF from {search_dir}/{fname}",
@@ -1130,6 +1134,8 @@ async def on_print_start(printer_id: int, data: dict):
printer.access_code,
f"{search_dir}/{fname}",
temp_path,
socket_timeout=ftp_timeout,
printer_model=printer.model,
)
if downloaded:
downloaded_filename = fname
@@ -1413,7 +1419,9 @@ async def _scan_for_timelapse_with_retries(archive_id: int):
found_path = None
for timelapse_path in ["/timelapse", "/timelapse/video", "/record", "/recording"]:
try:
found_files = await list_files_async(printer.ip_address, printer.access_code, timelapse_path)
found_files = await list_files_async(
printer.ip_address, printer.access_code, timelapse_path, printer_model=printer.model
)
if found_files:
files = found_files
found_path = timelapse_path
@@ -1455,7 +1463,9 @@ async def _scan_for_timelapse_with_retries(archive_id: int):
# 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("[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)
timelapse_data = await download_file_bytes_async(
printer.ip_address, printer.access_code, remote_path, printer_model=printer.model
)
if timelapse_data:
success = await service.attach_timelapse(archive_id, timelapse_data, file_name)
+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:
+1
View File
@@ -140,6 +140,7 @@ class FirmwareUpdateService:
storage_info = await get_storage_info_async(
printer.ip_address,
printer.access_code,
printer_model=printer.model,
)
if storage_info and "free_bytes" in storage_info:
result["sd_card_free_space"] = storage_info["free_bytes"]
+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
+3 -6
View File
@@ -6,16 +6,17 @@ import { api } from '../api/client';
import type { SpoolmanSyncResult, Printer } from '../api/client';
import { Card, CardContent, CardHeader } from './Card';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
export function SpoolmanSettings() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const [localEnabled, setLocalEnabled] = useState(false);
const [localUrl, setLocalUrl] = useState('');
const [localSyncMode, setLocalSyncMode] = useState('auto');
const [localDisableWeightSync, setLocalDisableWeightSync] = useState(false);
const [localReportPartialUsage, setLocalReportPartialUsage] = useState(true);
const [showSaved, setShowSaved] = useState(false);
const [selectedPrinterId, setSelectedPrinterId] = useState<number | 'all'>('all');
const [isInitialized, setIsInitialized] = useState(false);
const [showAllSkipped, setShowAllSkipped] = useState(false);
@@ -85,8 +86,7 @@ export function SpoolmanSettings() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['spoolman-settings'] });
queryClient.invalidateQueries({ queryKey: ['spoolman-status'] });
setShowSaved(true);
setTimeout(() => setShowSaved(false), 2000);
showToast(t('settings.toast.settingsSaved'));
},
});
@@ -169,9 +169,6 @@ export function SpoolmanSettings() {
{saveMutation.isPending && (
<Loader2 className="w-4 h-4 text-bambu-green animate-spin" />
)}
{showSaved && (
<Check className="w-4 h-4 text-bambu-green" />
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -23,7 +23,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-AXQRHtw2.js"></script>
<script type="module" crossorigin src="/assets/index-BZQD54OI.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-togsBDt6.css">
</head>
<body>