Skip to content

Instantly share code, notes, and snippets.

@steveneaston
Last active January 27, 2025 12:41
Show Gist options
  • Save steveneaston/24f20e67429b72cc3aef1565f908a1f4 to your computer and use it in GitHub Desktop.
Save steveneaston/24f20e67429b72cc3aef1565f908a1f4 to your computer and use it in GitHub Desktop.
Extract thumbnail from 3mf via Bambu Lab printer FTPS

Intro

Downloads a 3MF file from a Bambu Lab printer via it's FTPS service when the printer is in LAN mode. The embedded thumbnail is then extracted from the 3MF file.

This script makes little-to-no effort to validate files exist or are in the correct format and is provided as-is. I take no responsibility for any loss of data or damages caused. Use at your own risk.

Usage

Run the script with arguments <host> <access_code> <model name>. Example:

python3 bblab_fetch_image.py 192.168.1.123 12345678 "My super model.3mf"

By default models are downloaded to ./models, and images to ./images

import os
import ssl
import sys
import ftplib
import zipfile
import xml.etree.ElementTree as ET
class ImplicitFTP_TLS(ftplib.FTP_TLS):
"""
FTP_TLS subclass that automatically wraps sockets in SSL to support implicit FTPS.
see https://stackoverflow.com/a/36049814
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._sock = None
@property
def sock(self):
"""Return the socket."""
return self._sock
@sock.setter
def sock(self, value):
"""When modifying the socket, ensure that it is ssl wrapped."""
if value is not None and not isinstance(value, ssl.SSLSocket):
value = self.context.wrap_socket(value)
self._sock = value
def download_model(host, access_code, model, download_path='.'):
download_path = os.path.abspath(download_path)
os.makedirs(download_path, exist_ok=True)
ftp = ImplicitFTP_TLS()
ftp.connect(host=host, port=990, timeout=10)
ftp.login(user='bblp', passwd=access_code)
ftp.prot_p()
# Sometimes the 3mf is present in the FTP root, other times it's in
# "cache", try both.
with open(f'{download_path}/{model}', 'wb') as fp:
try:
ftp.retrbinary(f'RETR {model}', fp.write)
except:
ftp.cwd('cache')
ftp.retrbinary(f'RETR {model}', fp.write)
ftp.quit()
def extract_image(model_path, extract_path="."):
"""
Extracts a single file from a 3MF (ZIP) archive.
"""
if not zipfile.is_zipfile(model_path):
print(f"Error: {model_path} is not a valid 3MF file.")
return
try:
with zipfile.ZipFile(model_path, 'r') as archive:
# Attempt to fetch the name of the plate being printed
# from one of two relationships files
plate = None
if '_rels/.rels' in archive.namelist():
plate = get_plate(archive.read('_rels/.rels'))
elif plate is None and 'Metadata/_rels/model_settings.config.rels' in archive.namelist():
plate = get_plate(archive.read('Metadata/_rels/model_settings.config.rels'), 'http://schemas.bambulab.com/package/2021/gcode')
else:
plate = 'Metadata/plate_1'
image_path = f'{plate}.png'
if image_path in archive.namelist():
extract_name = f'{os.path.splitext(os.path.basename(model_path))[0]}.png'
extract_path = os.path.abspath(extract_path)
image = archive.getinfo(image_path)
image.filename = extract_name
archive.extract(image, extract_path)
print(f"Extracted {image_path} to {extract_path}/{extract_name}")
else:
print(f"Error: {image_path} not found in the 3MF archive.")
except Exception as e:
print(f"An error occurred: {e}")
def get_plate(rels, type='http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail'):
try:
root = ET.fromstring(rels)
for child in root:
if (child.attrib.get('Type') == type):
return os.path.splitext(child.attrib.get('Target'))[0].removeprefix('/')
except Exception as e:
return None
if __name__ == "__main__":
if len(sys.argv) < 4:
print("Host, access code and model name required")
else:
printer_ip = sys.argv[1]
access_code = sys.argv[2]
model_name = sys.argv[3]
download_path = 'models'
image_path = 'images'
download_model(host=printer_ip, access_code=access_code, model=model_name, download_path=download_path)
extract_image(f'{download_path}/{model_name}', extract_path=image_path)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment