bambuproject
bambuproject provides a means of accessing the current active print job's attributes
as well as the metadata associated with the underlying 3mf file being used to drive
the job.
Classes:
| Name | Description |
|---|---|
ActiveJobInfo |
The details of the currently active job running on the printer. |
ProjectInfo |
The details of a parsed |
Functions:
| Name | Description |
|---|---|
get_3mf_entry_by_id |
Depth-first search of the SD card file-tree dict returned by |
get_3mf_entry_by_name |
Depth-first search of the SD card file-tree dict returned by |
get_all_project_info |
Return one populated |
get_project_info |
Parse a |
ActiveJobInfo
dataclass
ActiveJobInfo(
project_info: ProjectInfo | None = ProjectInfo(),
project_file_command: dict = dict(),
stage_id: int = 0,
stage_name: str = "",
current_layer: int = 0,
total_layers: int = 0,
print_percentage: int = 0,
elapsed_minutes: int = 0,
remaining_minutes: int = 0,
wall_start_time: float = -1.0,
subtask_name: str = "",
gcode_file: str = "",
print_type: str = "",
plate_num: int = -1,
plate_type: PlateType = NONE,
project_info_fetch_attempted: bool = False,
)
The details of the currently active job running on the printer.
Attributes:
| Name | Type | Description |
|---|---|---|
current_layer |
int
|
Layer index. |
elapsed_minutes |
int
|
The elapsed time in minutes for this (or the last) job |
gcode_file |
str
|
The underlying gcode filename from this job feeding the printer. |
plate_num |
int
|
The plate number this job is targetting. |
plate_type |
PlateType
|
The plate type associated with the job |
print_percentage |
int
|
Completion %. |
print_type |
str
|
Indicates whether this is a cloud or local job (should always be local). |
project_file_command |
dict
|
The project_file command that triggered this job (if one did) |
project_info |
ProjectInfo | None
|
The |
project_info_fetch_attempted |
bool
|
True once a fallback fetch of project_info has been attempted, to prevent repeated FTP calls. |
remaining_minutes |
int
|
Time remaining in minutes for the current job. |
stage_id |
int
|
Current Stage numeric ID. |
stage_name |
str
|
Current Stage human name. |
subtask_name |
str
|
The subtask name for this job. |
total_layers |
int
|
The total number of layers for this job. |
wall_start_time |
float
|
Wall-clock timestamp (time.time()) of when this job started. Persisted to disk |
elapsed_minutes
class-attribute
instance-attribute
The elapsed time in minutes for this (or the last) job
gcode_file
class-attribute
instance-attribute
The underlying gcode filename from this job feeding the printer.
plate_num
class-attribute
instance-attribute
The plate number this job is targetting.
plate_type
class-attribute
instance-attribute
plate_type: PlateType = NONE
The plate type associated with the job
print_type
class-attribute
instance-attribute
Indicates whether this is a cloud or local job (should always be local).
project_file_command
class-attribute
instance-attribute
The project_file command that triggered this job (if one did)
project_info
class-attribute
instance-attribute
project_info: ProjectInfo | None = field(default_factory=ProjectInfo)
The 3mf details for the active job.
project_info_fetch_attempted
class-attribute
instance-attribute
True once a fallback fetch of project_info has been attempted, to prevent repeated FTP calls.
remaining_minutes
class-attribute
instance-attribute
Time remaining in minutes for the current job.
subtask_name
class-attribute
instance-attribute
The subtask name for this job.
total_layers
class-attribute
instance-attribute
The total number of layers for this job.
ProjectInfo
dataclass
ProjectInfo(
id: str = "",
name: str = "",
size: int = 0,
timestamp: int = 0,
md5: str = "",
plate_num: int = 1,
metadata: dict = dict(),
plates: list[int] = list(),
)
The details of a parsed .3mf project file for a single plate.
A .3mf file is a ZIP archive. get_project_info extracts the following
internal entries to populate this object:
| ZIP entry | Purpose |
|---|---|
Metadata/slice_info.config |
XML — objects, filament IDs, colors, filament_maps. |
Metadata/project_settings.config |
INI — filament types and colors (fallback) |
Metadata/plate_N.json |
JSON — bounding boxes, filament IDs/colors per plate |
Metadata/plate_N.png |
PNG — slicer preview thumbnail for plate N |
Metadata/top_N.png |
PNG — top-down view thumbnail for plate N |
Metadata/plate_N.gcode |
G-code header — filament type/color fallback (optional) |
The metadata dict produced by get_project_info has these keys:
| Key | Type | Description |
|---|---|---|
thumbnail |
str |
data:image/png;base64,... — plate_N.png as a data URI |
topimg |
str |
data:image/png;base64,... — top_N.png as a data URI |
map |
dict |
Full plate_N.json content, including filament_ids, |
filament_colors, and bbox_objects (each enriched with id |
||
from slice_info.config). |
||
filament |
list[dict] |
Normalized per-filament list: `{"id": int, "type": str, |
"color": "#RRGGBB"}.id` is 1-indexed. |
||
ams_mapping |
list[str] |
Placeholder in the SHAPE of the print_3mf_file ams_mapping |
param: index id - 1 holds str(id) per filament, "-1" |
||
fills gaps. NOT tray IDs — the 3mf's filament_maps is the |
||
| slicer's extruder assignment, so a real mapping must be | ||
| resolved from the spools loaded on the printer. | ||
filament_extruders |
list[int] |
The slicer's 1-based logical extruder per filament, at index |
id - 1, from slice_info.config filament_maps. Empty when |
||
| the slicer wrote none. | ||
physical_extruder_map |
list[int] |
Physical extruder (0 main, 1 deputy) per logical extruder, |
| from the plate gcode config block. Empty when absent. On H2D | ||
it is [1, 0], so logical extruder 1 is LEFT. |
||
external_spool_trays |
list[int] |
Wire id of the external spool holder feeding each filament, at |
index id - 1: 255 main (right), 254 deputy (left), -1 |
||
unused. Single-nozzle printers report 255 for every used |
||
| filament, although their telemetry calls the one holder tray | ||
254, so do not join this to BambuSpool.slot_id. Empty when |
||
| a dual-nozzle plate has no extruder map. Derived on every | ||
| read, never cached. |
Attributes:
| Name | Type | Description |
|---|---|---|
id |
str
|
The unique identifier for this project ( |
md5 |
str
|
The md5 checksum of this |
metadata |
dict
|
Extracted metadata for |
name |
str
|
The filename portion of the |
plate_num |
int
|
The plate number this |
plates |
list[int]
|
The plate numbers contained within this |
size |
int
|
The size of this |
timestamp |
int
|
The epoch timestamp of this |
id
class-attribute
instance-attribute
The unique identifier for this project (3mf storage location).
metadata
class-attribute
instance-attribute
Extracted metadata for plate_num, populated by get_project_info.
Keys
thumbnail : str
Slicer preview image for plate N as a data:image/png;base64,... URI.
Sourced from Metadata/plate_N.png inside the .3mf.
topimg : str
Top-down view of plate N as a data:image/png;base64,... URI.
Sourced from Metadata/top_N.png inside the .3mf.
map : dict
Full contents of Metadata/plate_N.json, including:
- `filament_ids` : `list[str]` — slicer filament slot IDs for this plate
- `filament_colors` : `list[str]` — `#RRGGBB` color per filament slot
- `bbox_objects` : `list[dict]` — one entry per printable object on the plate.
Each entry is enriched by `get_project_info` with an `"id"` key whose value
is the integer `identify_id` from `Metadata/slice_info.config`. These IDs
are passed directly to `BambuPrinter.skip_objects` to cancel individual
objects mid-print. Each entry also carries:
| Field | Type | Description |
|----------|---------|-----------------------------------------------------|
| `id` | `int` | `identify_id` from `slice_info.config` (added by |
| | | `get_project_info`) |
| `name` | `str` | Human-readable object name from the slicer |
| `val` | `list` | Bounding-box extents `[x_min, y_min, x_max, y_max]` |
filament : list[dict]
Normalized per-filament list extracted from Metadata/slice_info.config.
Falls back to Metadata/project_settings.config then the plate_N.gcode
header when slice_info.config is absent or sparse.
Each element: `{"id": int, "type": str, "color": "#RRGGBB"}`
- `id` is 1-indexed (matches the slicer's filament slot numbering)
- `type` is the filament material string (e.g. `"PLA"`, `"PETG-CF"`)
- `color` is the slicer colour in `#RRGGBB` format
ams_mapping : list[str]
A placeholder in the SHAPE of BambuPrinter.print_3mf_file's
ams_mapping parameter, not a slot assignment: index id - 1 holds
str(id) for every filament in filament, "-1" fills the gaps. The
3mf carries no tray ids — its filament_maps value is the slicer's
per-filament extruder choice (1 or 2 on H2D) — so the real mapping
must be resolved from the spools currently loaded on the printer
(match each filament's type/color to a spool, then encode that
spool's tray id as below). Pass the resolved list, serialised to a
JSON string, to print_3mf_file.
| Value | Meaning |
|----------|------------------------------------------------------------|
| `0–103` | Standard 4-slot AMS: `ams_id * 4 + slot_id` |
| `128–135`| Single-slot AMS HT / N3S: `ams_id` (starts at 128) |
| `255` | External spool, main (right on H2D) extruder's holder |
| `254` | External spool, deputy (left on H2D) extruder's holder |
| `-1` | Unmapped — filament not assigned to any AMS slot |
Do not pass `254` or `255` for an external-spool print: pass `use_ams=False`
and let `print_3mf_file` derive the holder (see `external_spool_trays`).
filament_extruders : list[int]
The slicer's 1-based logical extruder per filament, at index id - 1, from
filament_maps in Metadata/slice_info.config. Empty when absent.
physical_extruder_map : list[int]
Physical extruder per logical extruder, from the config block of
Metadata/plate_N.gcode: 0 main, 1 deputy. On H2D it is [1, 0], so
logical extruder 1 is the LEFT extruder. Empty when absent.
external_spool_trays : list[int]
The external spool holder that feeds each filament, at index id - 1:
255 for the main (right on H2D) holder, 254 for the deputy (left) holder,
-1 for a filament the plate does not use. Single-nozzle printers report
255 for every used filament because they have one holder. Empty when a
dual-nozzle plate lacks filament_extruders or physical_extruder_map, which
is also when print_3mf_file refuses the print. These are the ids used in
ams_mapping2, not telemetry tray ids: a single-nozzle printer's telemetry
reports its one holder as tray 254, so do not join this field to
BambuSpool.slot_id. Derived on every read from the printer's capabilities
and never written to the metadata cache.
slicer_settings : dict
Key slicer parameters extracted from Metadata/project_settings.config.
Present only when that file exists in the .3mf. Falls back to empty dict
when the file is absent (e.g. older BambuStudio exports).
| Key | Type | Description |
|------------------------|-------|--------------------------------------------------|
| `enable_support` | `str` | `"1"` if support structures are enabled |
| `support_type` | `str` | `"normal"`, `"tree"`, etc. |
| `brim_type` | `str` | `"no_brim"`, `"outer_brim"`, `"inner_brim"` etc. |
| `brim_width` | `str` | Brim width in mm (string, e.g. `"5"`) |
| `raft_layers` | `str` | Number of raft layers (`"0"` = no raft) |
| `sparse_infill_density`| `str` | Infill density, e.g. `"15%"` |
| `wall_loops` | `str` | Number of perimeter walls |
| `layer_height` | `str` | Layer height in mm |
| `initial_layer_height` | `str` | First layer height in mm |
plate_num
class-attribute
instance-attribute
The plate number this 3mf targets.
plates
class-attribute
instance-attribute
The plate numbers contained within this 3mf.
get_3mf_entry_by_id
Depth-first search of the SD card file-tree dict returned by
BambuPrinter.get_sdcard_3mf_files() / get_sdcard_contents(), matching
on the id field (full SD card path, e.g. /jobs/my_project.3mf).
Parameters
- node : dict - Root or intermediate tree node. Each node has the shape
{"id": str, "name": str, "size": int, "timestamp": int, "children": list}. - target_id : str - The full SD card path to find (e.g.
"/jobs/my_project.3mf").
Returns
dict if a matching node is found, None otherwise (including when node is None,
which is what get_sdcard_3mf_files() returns after a failed listing).
Source code in src/bpm/bambuproject.py
def get_3mf_entry_by_id(node: dict | Any, target_id: str):
"""
Depth-first search of the SD card file-tree dict returned by
`BambuPrinter.get_sdcard_3mf_files()` / `get_sdcard_contents()`, matching
on the `id` field (full SD card path, e.g. `/jobs/my_project.3mf`).
Parameters
----------
* node : dict - Root or intermediate tree node. Each node has the shape
`{"id": str, "name": str, "size": int, "timestamp": int, "children": list}`.
* target_id : str - The full SD card path to find (e.g. `"/jobs/my_project.3mf"`).
Returns
-------
`dict` if a matching node is found, `None` otherwise (including when `node` is `None`,
which is what `get_sdcard_3mf_files()` returns after a failed listing).
"""
if not isinstance(node, dict):
return None
if node.get("id") == target_id:
return node
if "children" in node and isinstance(node["children"], list):
for child in node["children"]:
found_entry = get_3mf_entry_by_id(child, target_id)
if found_entry is not None:
return found_entry
return None
get_3mf_entry_by_name
Depth-first search of the SD card file-tree dict returned by
BambuPrinter.get_sdcard_3mf_files() / get_sdcard_contents(), matching
on the name field (filename portion, no path).
Parameters
- node : dict - Root or intermediate tree node. Each node has the shape
{"id": str, "name": str, "children": list, ...}. - target_name : str - The filename to find (e.g.
"my_project.3mf").
Returns
dict if a matching node is found, None otherwise (including when node is None,
which is what get_sdcard_3mf_files() returns after a failed listing).
Source code in src/bpm/bambuproject.py
def get_3mf_entry_by_name(node: dict | Any, target_name: str):
"""
Depth-first search of the SD card file-tree dict returned by
`BambuPrinter.get_sdcard_3mf_files()` / `get_sdcard_contents()`, matching
on the `name` field (filename portion, no path).
Parameters
----------
* node : dict - Root or intermediate tree node. Each node has the shape
`{"id": str, "name": str, "children": list, ...}`.
* target_name : str - The filename to find (e.g. `"my_project.3mf"`).
Returns
-------
`dict` if a matching node is found, `None` otherwise (including when `node` is `None`,
which is what `get_sdcard_3mf_files()` returns after a failed listing).
"""
if not isinstance(node, dict):
return None
if node.get("name") == target_name:
return node
if "children" in node and isinstance(node["children"], list):
for child in node["children"]:
found_entry = get_3mf_entry_by_name(child, target_name)
if found_entry is not None:
return found_entry
return None
get_all_project_info
get_all_project_info(
project_file_id: str,
printer: BambuPrinter,
project_file_md5: str | None = None,
local_file: str = "",
use_cached_list: bool = False,
max_plates: int = 30,
) -> list[ProjectInfo]
Return one populated ProjectInfo per plate in a .3mf, ordered by plate number.
Batch counterpart to get_project_info: a single call yields the metadata for
every plate in the file (plate number, thumbnail, filament list, ams_mapping,
etc.), so a caller building a plate picker / AMS-mapping UI no longer needs one
round-trip per plate.
This is a thin wrapper over get_project_info and adds no parsing cost. The
.3mf is downloaded and unzipped exactly once (the first lookup), which caches
every plate's metadata to disk as a side effect; the remaining per-plate lookups
are served from that cache.
Plate numbers are NOT assumed to be contiguous or to start at 1. A .3mf may
contain an arbitrary, sparse set of plates (e.g. just [10], or
[1, 5, 6, 7, 8, 12, 15]) — the user may have deleted plates in the slicer. The
actual set is taken from ProjectInfo.plates (derived from the zip's
Metadata/plate_*.json entries) and bounded by max_plates as a safety ceiling.
Only plates that genuinely exist are fetched, so no absent-plate probing occurs —
a blind probe of a missing plate would wipe the metadata cache and force a full
re-download (see get_project_info).
Parameters
- project_file_id : str - Full SD card path to the
.3mf(seeget_project_info). - printer : BambuPrinter - The printer whose SD card hosts the file.
- project_file_md5 : Optional[str] =
None- Cache-validation md5 (seeget_project_info). - local_file : str =
""- Parse this local path instead of downloading. - use_cached_list : bool =
False- Reuse the cached SD card file listing. - max_plates : int =
30- Safety ceiling on plate number; plates outside1..max_platesare ignored. A.3mfis assumed never to exceed this many plates.
Returns
list[ProjectInfo] - One entry per existing plate (deduplicated), sorted by
plate_num. Empty list if the file could not be parsed.
Examples
get_all_project_info("/jobs/my_project.3mf", printer)— every plate's info in one call
Source code in src/bpm/bambuproject.py
def get_all_project_info(
project_file_id: str,
printer: "BambuPrinter",
project_file_md5: str | None = None,
local_file: str = "",
use_cached_list: bool = False,
max_plates: int = 30,
) -> list[ProjectInfo]:
"""
Return one populated `ProjectInfo` per plate in a `.3mf`, ordered by plate number.
Batch counterpart to `get_project_info`: a single call yields the metadata for
*every* plate in the file (plate number, thumbnail, filament list, `ams_mapping`,
etc.), so a caller building a plate picker / AMS-mapping UI no longer needs one
round-trip per plate.
This is a thin wrapper over `get_project_info` and adds no parsing cost. The
`.3mf` is downloaded and unzipped exactly once (the first lookup), which caches
every plate's metadata to disk as a side effect; the remaining per-plate lookups
are served from that cache.
Plate numbers are NOT assumed to be contiguous or to start at 1. A `.3mf` may
contain an arbitrary, sparse set of plates (e.g. just `[10]`, or
`[1, 5, 6, 7, 8, 12, 15]`) — the user may have deleted plates in the slicer. The
actual set is taken from `ProjectInfo.plates` (derived from the zip's
`Metadata/plate_*.json` entries) and bounded by `max_plates` as a safety ceiling.
Only plates that genuinely exist are fetched, so no absent-plate probing occurs —
a blind probe of a missing plate would wipe the metadata cache and force a full
re-download (see `get_project_info`).
Parameters
----------
* project_file_id : str - Full SD card path to the `.3mf` (see `get_project_info`).
* printer : BambuPrinter - The printer whose SD card hosts the file.
* project_file_md5 : Optional[str] = `None` - Cache-validation md5 (see `get_project_info`).
* local_file : str = `""` - Parse this local path instead of downloading.
* use_cached_list : bool = `False` - Reuse the cached SD card file listing.
* max_plates : int = `30` - Safety ceiling on plate number; plates outside
`1..max_plates` are ignored. A `.3mf` is assumed never to exceed this many plates.
Returns
-------
`list[ProjectInfo]` - One entry per existing plate (deduplicated), sorted by
`plate_num`. Empty list if the file could not be parsed.
Examples
--------
* `get_all_project_info("/jobs/my_project.3mf", printer)` — every plate's info in one call
"""
# One lookup parses the .3mf once and reports the actual plate set via
# ProjectInfo.plates (any sparse arrangement). plate_num=1 is only the parse
# trigger — get_project_info falls back to the first real plate if 1 is absent,
# so this does NOT assume plate 1 exists.
first = get_project_info(
project_file_id,
printer,
project_file_md5,
plate_num=1,
local_file=local_file,
use_cached_list=use_cached_list,
)
if first is None:
return []
plate_nums = sorted({p for p in first.plates if 1 <= p <= max_plates})
by_plate: dict[int, ProjectInfo] = {}
if first.plate_num in plate_nums:
by_plate[first.plate_num] = first
for plate in plate_nums:
if plate in by_plate:
continue
info = get_project_info(
project_file_id,
printer,
project_file_md5,
plate_num=plate,
use_cached_list=True,
)
# get_project_info falls back to another plate when `plate` is absent;
# keep only a genuine match (and dedupe via the dict).
if info is not None and info.plate_num == plate:
by_plate[plate] = info
return [by_plate[plate] for plate in sorted(by_plate)]
get_project_info
get_project_info(
project_file_id: str,
printer: BambuPrinter,
project_file_md5: str | None = None,
plate_num: int = 1,
local_file: str = "",
use_cached_list: bool = False,
) -> ProjectInfo | None
Parse a .3mf file and return a populated ProjectInfo instance for the
requested plate, using a local metadata cache to avoid repeated FTPS downloads.
Resolution order
- If a valid cached metadata file exists at
{bpm_cache_path}/{serial_number}/metadata/{sd_card_path_with_dashes}-{plate_num}.jsonand the SD card entry'stimestamp+sizematch (orproject_file_md5matches the cachedmd5), the cached data is returned immediately — no download. - Otherwise the
.3mfis downloaded from the printer's SD card via FTPS, every plate is extracted and cached separately, and the local copy is deleted. - If
local_fileis supplied the download step is skipped entirely and the provided path is parsed directly (used duringupload_sdcard_file).
What is extracted per plate
| Source in ZIP | Metadata key | Description |
|---|---|---|
Metadata/plate_N.png |
thumbnail |
Data-URI PNG — slicer preview |
Metadata/top_N.png |
topimg |
Data-URI PNG — top-down view |
Metadata/plate_N.json |
map |
Raw plate JSON (bbox_objects, filament_ids, |
| filament_colors) | ||
Metadata/slice_info.config (XML) |
filament |
[{"id": int, "type": str, "color": "#RRGGBB"}, ...] |
Metadata/slice_info.config (XML) |
ams_mapping |
Placeholder shape, NOT tray IDs: index id - 1 holds |
str(id) per filament, "-1" fills gaps |
||
Metadata/slice_info.config (XML) |
filament_extruders |
1-based logical extruder per filament |
Metadata/plate_N.gcode config block |
physical_extruder_map |
Physical extruder per logical extruder, read up to |
CONFIG_BLOCK_END |
||
| (derived) | external_spool_trays |
Derived from the two rows above and the printer's nozzle |
| count on every read, never cached | ||
Metadata/project_settings.config |
(fallback) | Filament type + color when slice_info is sparse |
Metadata/plate_N.gcode header |
(fallback) | Filament type + color when both above are absent |
Metadata cached before filament_extruders existed is re-parsed once.
bbox_objects entries in map are enriched with integer id values sourced
from the identify_id attribute in slice_info.config. These id values are
the ones passed directly to BambuPrinter.skip_objects to cancel individual
objects mid-print. Each entry also carries name (human-readable slicer name)
and bounding-box coordinates useful for building a per-object cancel UI.
ams_mapping is NOT the BambuStudio/OrcaSlicer DevMapping.cpp tray-id encoding
(0–103 standard 4-slot AMS, 128–135 AMS HT/N3S, 254 external, -1 unmapped)
— that encoding is what BambuPrinter.print_3mf_file's ams_mapping parameter
expects, but the 3mf's own filament_maps carries the slicer's per-filament
EXTRUDER choice (½ on H2D), not a tray id, so this placeholder must be
resolved against the spools actually loaded on the printer before use.
Parameters
- project_file_id : str - Full SD card path to the
.3mffile (e.g."/jobs/my_project.3mf"). A leading/is prepended if absent. - printer : BambuPrinter - Connected printer instance used for SD card queries and FTPS downloads.
- project_file_md5 : Optional[str] - If the caller already knows the file's MD5 (uppercase hex), providing it bypasses the SD card listing and validates the cache purely by checksum — significantly faster.
- plate_num : int = 1 - The 1-indexed plate number to return metadata for.
If the requested plate is absent in the
.3mf, the first available plate is used instead. - local_file : str = "" - Path to an already-downloaded copy of the
.3mfon the local filesystem. When set, the FTPS download and SD card listing are skipped entirely. - use_cached_list : bool = False - When
True,printer.cached_sd_card_3mf_filesis used instead of issuing a freshget_sdcard_3mf_files()call.
Returns
ProjectInfo for plate_num if successful, None if the file cannot be
located on the SD card or the requested plate has no parseable metadata.
Raises
Exception if local_file is not set and the file does not exist on the
printer's SD card.
Source code in src/bpm/bambuproject.py
def get_project_info(
project_file_id: str,
printer: "BambuPrinter",
project_file_md5: str | None = None,
plate_num: int = 1,
local_file: str = "",
use_cached_list: bool = False,
) -> ProjectInfo | None:
"""
Parse a `.3mf` file and return a populated `ProjectInfo` instance for the
requested plate, using a local metadata cache to avoid repeated FTPS downloads.
**Resolution order**
1. If a valid cached metadata file exists at
`{bpm_cache_path}/{serial_number}/metadata/{sd_card_path_with_dashes}-{plate_num}.json` **and** the SD card
entry's `timestamp` + `size` match (or `project_file_md5` matches the cached
`md5`), the cached data is returned immediately — no download.
2. Otherwise the `.3mf` is downloaded from the printer's SD card via FTPS,
every plate is extracted and cached separately, and the local copy is deleted.
3. If `local_file` is supplied the download step is skipped entirely and the
provided path is parsed directly (used during `upload_sdcard_file`).
**What is extracted per plate**
| Source in ZIP | Metadata key | Description |
|---------------------------------------|-------------------------|----------------------------------------------------------|
| `Metadata/plate_N.png` | `thumbnail` | Data-URI PNG — slicer preview |
| `Metadata/top_N.png` | `topimg` | Data-URI PNG — top-down view |
| `Metadata/plate_N.json` | `map` | Raw plate JSON (bbox_objects, filament_ids, |
| | | filament_colors) |
| `Metadata/slice_info.config` (XML) | `filament` | `[{"id": int, "type": str, "color": "#RRGGBB"}, ...]` |
| `Metadata/slice_info.config` (XML) | `ams_mapping` | Placeholder shape, NOT tray IDs: index `id - 1` holds |
| | | `str(id)` per filament, `"-1"` fills gaps |
| `Metadata/slice_info.config` (XML) | `filament_extruders` | 1-based logical extruder per filament |
| `Metadata/plate_N.gcode` config block | `physical_extruder_map` | Physical extruder per logical extruder, read up to |
| | | `CONFIG_BLOCK_END` |
| *(derived)* | `external_spool_trays` | Derived from the two rows above and the printer's nozzle |
| | | count on every read, never cached |
| `Metadata/project_settings.config` | *(fallback)* | Filament type + color when slice_info is sparse |
| `Metadata/plate_N.gcode` header | *(fallback)* | Filament type + color when both above are absent |
Metadata cached before `filament_extruders` existed is re-parsed once.
`bbox_objects` entries in `map` are enriched with integer `id` values sourced
from the `identify_id` attribute in `slice_info.config`. These `id` values are
the ones passed directly to `BambuPrinter.skip_objects` to cancel individual
objects mid-print. Each entry also carries `name` (human-readable slicer name)
and bounding-box coordinates useful for building a per-object cancel UI.
`ams_mapping` is NOT the BambuStudio/OrcaSlicer DevMapping.cpp tray-id encoding
(`0–103` standard 4-slot AMS, `128–135` AMS HT/N3S, `254` external, `-1` unmapped)
— that encoding is what `BambuPrinter.print_3mf_file`'s `ams_mapping` parameter
expects, but the 3mf's own `filament_maps` carries the slicer's per-filament
EXTRUDER choice (1/2 on H2D), not a tray id, so this placeholder must be
resolved against the spools actually loaded on the printer before use.
Parameters
----------
* project_file_id : str - Full SD card path to the `.3mf` file
(e.g. `"/jobs/my_project.3mf"`). A leading `/` is prepended if absent.
* printer : BambuPrinter - Connected printer instance used for SD card queries
and FTPS downloads.
* project_file_md5 : Optional[str] - If the caller already knows the file's MD5
(uppercase hex), providing it bypasses the SD card listing and validates the
cache purely by checksum — significantly faster.
* plate_num : int = 1 - The 1-indexed plate number to return metadata for.
If the requested plate is absent in the `.3mf`, the first available plate
is used instead.
* local_file : str = "" - Path to an already-downloaded copy of the `.3mf` on
the local filesystem. When set, the FTPS download and SD card listing are
skipped entirely.
* use_cached_list : bool = False - When `True`, `printer.cached_sd_card_3mf_files`
is used instead of issuing a fresh `get_sdcard_3mf_files()` call.
Returns
-------
`ProjectInfo` for `plate_num` if successful, `None` if the file cannot be
located on the SD card or the requested plate has no parseable metadata.
Raises
------
`Exception` if `local_file` is not set and the file does not exist on the
printer's SD card.
"""
def get_nodes_by_plate_id(xml_root, plate_id, node_name):
for plate in xml_root.findall("plate"):
meta = plate.find("./metadata[@key='index']")
if meta is not None and meta.get("value") == str(plate_id):
return plate.findall(node_name)
return []
def _split_config_list(value: str) -> list[str]:
raw = value.strip()
if not raw:
return []
if raw.startswith("[") and raw.endswith("]"):
raw = raw[1:-1]
parts = [part.strip().strip('"').strip("'") for part in re.split(r"[;,]", raw)]
return [part for part in parts if part]
def _extract_list_from_config(config_text: str, key: str) -> list[str]:
if not config_text:
return []
for line in config_text.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if not stripped.startswith(f"{key}"):
continue
if "=" not in stripped:
continue
_, value = stripped.split("=", 1)
values = _split_config_list(value)
if values:
return values
xml_match = re.search(rf'key="{re.escape(key)}"\s+value="([^"]*)"', config_text)
if xml_match:
return _split_config_list(xml_match.group(1))
return []
def _extract_list_from_gcode_header(gcode_text: str, key: str) -> list[str]:
if not gcode_text:
return []
pattern = re.compile(rf"^\s*;\s*{re.escape(key)}\s*=\s*(.*)$", re.IGNORECASE)
for line in gcode_text.splitlines():
match = pattern.match(line)
if match:
return _split_config_list(match.group(1))
return []
def _read_gcode_config_block(zf: ZipFile, plate: int) -> str:
# The slicer writes the config block near the top of the plate gcode, so read
# only up to its end marker instead of decompressing the whole toolpath.
lines: list[str] = []
try:
with zf.open(f"Metadata/plate_{plate}.gcode") as gcode:
for raw_line in gcode:
line = raw_line.decode("utf-8", errors="ignore")
lines.append(line)
if "CONFIG_BLOCK_END" in line or len(lines) >= 5000:
break
except KeyError:
return ""
return "".join(lines)
def _to_int_list(values: list[str]) -> list[int]:
return [int(value) for value in values if value.lstrip("-").isdigit()]
def _attach_external_spool_trays(metadata: dict[str, Any]) -> None:
# Derived on every return and never written to the metadata cache, because it
# depends on the printer's live capabilities, not only on the .3mf.
used = [int(filament["id"]) for filament in metadata.get("filament", [])]
if not printer.config.capabilities.has_dual_extruder:
metadata["external_spool_trays"] = [
VIRTUAL_TRAY_MAIN_ID if index + 1 in used else -1
for index in range(max(used, default=0))
]
return
try:
metadata["external_spool_trays"] = resolve_external_spool_trays(
metadata.get("filament_extruders", []),
metadata.get("physical_extruder_map", []),
used,
)
except ValueError:
metadata["external_spool_trays"] = []
def _normalize_hex_color(value: str) -> str:
color = value.strip().upper()
if not color:
return color
if color.startswith("#"):
color = color[1:]
if len(color) == 8:
color = color[:6]
if len(color) == 6 and re.fullmatch(r"[0-9A-F]{6}", color):
return f"#{color}"
return value.strip()
def _ensure_ams_mapping(metadata: dict[str, Any]) -> None:
if "ams_mapping" in metadata and isinstance(metadata["ams_mapping"], list):
normalized_mapping: list[str] = []
for item in metadata["ams_mapping"]:
try:
normalized_mapping.append(str(int(item)))
except (TypeError, ValueError):
normalized_mapping.append("-1")
metadata["ams_mapping"] = normalized_mapping
return
filament_list = metadata.get("filament", [])
map_ids = metadata.get("map", {}).get("filament_ids", [])
mapping_size = 0
if isinstance(map_ids, list) and map_ids:
mapping_size = len(map_ids)
elif isinstance(filament_list, list) and filament_list:
max_filament_id = 0
for filament in filament_list:
try:
max_filament_id = max(max_filament_id, int(filament.get("id", 0)))
except (TypeError, ValueError):
continue
mapping_size = max_filament_id if max_filament_id > 0 else len(filament_list)
if mapping_size <= 0:
metadata["ams_mapping"] = []
return
ams_mapping: list[str] = ["-1"] * mapping_size
if isinstance(filament_list, list):
for filament in filament_list:
try:
filament_id = int(filament.get("id", 0))
except (TypeError, ValueError):
continue
map_index = filament_id - 1
if 0 <= map_index < len(ams_mapping):
ams_mapping[map_index] = str(filament_id)
metadata["ams_mapping"] = ams_mapping
file = project_file_id
if not file.startswith("/"):
file = f"/{file}"
filename = file.lstrip("/").replace("/", "-")
serial = printer.config.serial_number
cache_path = (
printer.config.bpm_cache_path if printer.config.bpm_cache_path else Path()
)
if serial:
cache_path = cache_path / serial
(cache_path / "metadata").mkdir(parents=True, exist_ok=True)
metadata = cache_path / "metadata" / f"{filename}-{plate_num}.json"
localfile = cache_path / filename
if local_file:
localfile = Path(local_file)
remote_files = None
if not metadata.exists() and not local_file:
metapath = cache_path / "metadata"
# Match any plate number (single- AND multi-digit). A single-char "?" glob
# silently missed cached plates >= 10; numeric-filter + sort for determinism.
matches = sorted(
p
for p in metapath.glob(f"{filename}-*.json")
if re.fullmatch(rf"{re.escape(filename)}-\d+\.json", p.name)
)
if matches:
metadata = matches[0]
plate_num_match = re.search(
rf"{re.escape(filename)}-(\d+)\.json", metadata.name
)
if plate_num_match:
plate_num = int(plate_num_match.group(1))
logger.debug(
f"get_project_info - using fallback metadata file: [{metadata.name}] plate: [{plate_num}]"
)
if metadata.exists() and not local_file:
lmd = None
rmd = None
if not project_file_md5:
remote_files = (
printer.get_sdcard_3mf_files()
if not use_cached_list
else printer.cached_sd_card_3mf_files
)
rmd = get_3mf_entry_by_id(remote_files, file)
with metadata.open("r") as f:
lmd = json.load(f)
# Metadata cached before extruder assignments were kept lacks these keys, so
# it is re-parsed once instead of serving a plate with no holder information.
cache_has_extruders = "filament_extruders" in (lmd or {}).get("metadata", {})
if cache_has_extruders and (
(
lmd
and "plate_num" in lmd
and lmd["plate_num"] == plate_num
and rmd
and rmd["timestamp"] == lmd["timestamp"]
and rmd["size"] == lmd["size"]
)
or (
project_file_md5
and "md5" in lmd
and lmd["md5"] == project_file_md5.upper()
and "plate_num" in lmd
and lmd["plate_num"] == plate_num
)
):
logger.debug(f"get_project_info - using cached 3mf metadata for [{file}]")
pi = ProjectInfo()
pi.id = lmd["id"]
pi.name = lmd["name"]
pi.plates = lmd.get("plates", [])
pi.plate_num = plate_num
pi.md5 = lmd["md5"]
pi.timestamp = lmd["timestamp"]
pi.size = lmd["size"]
pi.metadata = lmd["metadata"]
_ensure_ams_mapping(pi.metadata)
_attach_external_spool_trays(pi.metadata)
return pi
if not local_file:
localfile.unlink(missing_ok=True)
# Clear any stale per-plate caches for this file before re-parsing, so plates
# removed in a re-slice don't linger. Glob the actual cache files (any plate
# number) instead of a hardcoded 1..30 range.
for md in (cache_path / "metadata").glob(f"{filename}-*.json"):
if re.fullmatch(rf"{re.escape(filename)}-\d+\.json", md.name):
md.unlink(missing_ok=True)
if not local_file and printer.sdcard_file_exists(file):
printer.download_sdcard_file(file, str(localfile))
elif not local_file:
raise Exception(f"get_project_info - [{file}] not found in sdcard_3mf_files")
thumbnail_png = None
top_view_png = None
plate_map = None
slice_info_cfg = None
project_settings_cfg = ""
plate_nums = []
with ZipFile(localfile, "r") as zf:
all_files = zf.namelist()
plate_pattern = "Metadata/plate_*.json"
plate_files = fnmatch.filter(all_files, plate_pattern)
for plate_file in plate_files:
num = plate_file.split("/")[-1].split(".")[0].split("_")[-1]
if num.isnumeric():
plate_nums.append(int(num))
if plate_num not in plate_nums:
num = plate_nums[0] if plate_nums else 1
logger.debug(
f"get_project_info - requested plate_num [{plate_num}] not found in 3mf metadata - defaulting to plate_num [{num}]"
)
plate_num = num
with ZipFile(localfile, "r") as zf:
with zf.open("Metadata/slice_info.config") as f:
slice_info_cfg = ET.fromstring(f.read().decode("utf-8"))
try:
with zf.open("Metadata/project_settings.config") as f:
project_settings_cfg = f.read().decode("utf-8", errors="ignore")
except KeyError:
project_settings_cfg = ""
ret = None
for num in plate_nums:
pi = ProjectInfo()
try:
with zf.open(f"Metadata/plate_{num}.json") as f:
plate_map = f.read()
with zf.open(f"Metadata/plate_{num}.png") as f:
thumbnail_png = f.read()
with zf.open(f"Metadata/top_{num}.png") as f:
top_view_png = f.read()
pi.metadata = {}
pi.metadata["thumbnail"] = (
f"data:image/png;base64,{base64.b64encode(thumbnail_png).decode()}"
)
pi.metadata["topimg"] = (
f"data:image/png;base64,{base64.b64encode(top_view_png).decode()}"
)
pi.metadata["map"] = json.loads(plate_map)
objects = get_nodes_by_plate_id(slice_info_cfg, num, "object")
idx = 0
for object in objects:
obj_id = object.get("identify_id", None)
if obj_id:
pi.metadata["map"]["bbox_objects"][idx]["id"] = int(obj_id)
else:
logger.warning(
f"get_project_info - Object at index [{idx}] missing 'identify_id' attribute; skipping id assignment"
)
idx += 1
filament_list = []
filaments = get_nodes_by_plate_id(slice_info_cfg, num, "filament")
for filament in filaments:
id = int(filament.get("id", -1))
type = filament.get("type", "")
color = filament.get("color", "")
filament_list.append({"id": id, "type": type, "color": color})
if not filament_list:
map_ids = pi.metadata["map"].get("filament_ids", [])
map_colors = pi.metadata["map"].get("filament_colors", [])
ps_types = _extract_list_from_config(
project_settings_cfg,
"filament_type",
)
ps_colors = _extract_list_from_config(
project_settings_cfg,
"filament_colour",
)
plate_gcode_header = ""
try:
with zf.open(f"Metadata/plate_{num}.gcode") as f:
plate_gcode_header = f.read().decode("utf-8", errors="ignore")
except KeyError:
plate_gcode_header = ""
gcode_types = _extract_list_from_gcode_header(
plate_gcode_header,
"filament_type",
)
gcode_colors = _extract_list_from_gcode_header(
plate_gcode_header,
"filament_colour",
)
fallback_filament_map: dict[int, dict[str, Any]] = {}
for idx, raw_id in enumerate(map_ids):
numeric_id = -1
if isinstance(raw_id, int):
numeric_id = raw_id
elif isinstance(raw_id, str) and raw_id.isdigit():
numeric_id = int(raw_id)
filament_id = numeric_id if numeric_id > 0 else idx + 1
filament_type = ""
if 0 <= numeric_id < len(ps_types):
filament_type = ps_types[numeric_id]
elif idx < len(ps_types):
filament_type = ps_types[idx]
elif 0 <= numeric_id < len(gcode_types):
filament_type = gcode_types[numeric_id]
elif idx < len(gcode_types):
filament_type = gcode_types[idx]
color = ""
if idx < len(map_colors):
color = map_colors[idx]
if not color and 0 <= numeric_id < len(ps_colors):
color = ps_colors[numeric_id]
if not color and idx < len(ps_colors):
color = ps_colors[idx]
if not color and 0 <= numeric_id < len(gcode_colors):
color = gcode_colors[numeric_id]
if not color and idx < len(gcode_colors):
color = gcode_colors[idx]
fallback_filament_map[filament_id] = {
"id": filament_id,
"type": filament_type,
"color": _normalize_hex_color(color),
}
if not fallback_filament_map and ps_types:
for idx, filament_type in enumerate(ps_types):
color = ""
if idx < len(ps_colors):
color = ps_colors[idx]
elif idx < len(gcode_colors):
color = gcode_colors[idx]
fallback_filament_map[idx + 1] = {
"id": idx + 1,
"type": filament_type,
"color": _normalize_hex_color(color),
}
filament_list = [
fallback_filament_map[key]
for key in sorted(fallback_filament_map.keys())
]
pi.metadata["filament"] = filament_list
filament_maps = []
filament_extruders: list[int] = []
slice_info_metadata = get_nodes_by_plate_id(
slice_info_cfg, num, "metadata"
)
for slice_meta in slice_info_metadata:
if slice_meta.get("key", "") == "filament_maps":
# `filament_maps` is the slicer's per-filament EXTRUDER
# assignment (1 or 2 on H2D), not tray ids, so it is unusable
# as an ams_mapping. Keep it as `filament_extruders`, then
# rebuild the value below as a filament-id placeholder;
# callers resolve real tray ids from the spools loaded on
# the printer.
filament_maps = slice_meta.get("value", "").split(" ")
filament_extruders = _to_int_list(filament_maps)
for f in range(0, len(filament_maps)):
filament_maps[f] = "-1"
break
if filament_maps:
for filament in pi.metadata["filament"]:
map_index = filament["id"] - 1
if 0 <= map_index < len(filament_maps):
filament_maps[map_index] = str(filament["id"])
pi.metadata["ams_mapping"] = filament_maps
pi.metadata["filament_extruders"] = filament_extruders
pi.metadata["physical_extruder_map"] = _to_int_list(
_extract_list_from_gcode_header(
_read_gcode_config_block(zf, num), "physical_extruder_map"
)
)
_ensure_ams_mapping(pi.metadata)
def _single(key, default=""):
vals = _extract_list_from_config(project_settings_cfg, key)
return vals[0] if vals else default
pi.metadata["slicer_settings"] = {
"enable_support": _single("enable_support", "0"),
"support_type": _single("support_type", "normal"),
"brim_type": _single("brim_type", "no_brim"),
"brim_width": _single("brim_width", "0"),
"raft_layers": _single("raft_layers", "0"),
"sparse_infill_density": _single("sparse_infill_density")
or _single("infill_density", "15%"),
"wall_loops": _single("wall_loops") or _single("perimeters", "2"),
"layer_height": _single("layer_height", "0.2"),
"initial_layer_height": _single("initial_layer_height", "0.2"),
}
if not remote_files:
remote_files = (
printer.get_sdcard_3mf_files()
if not use_cached_list
else printer.cached_sd_card_3mf_files
)
entry = get_3mf_entry_by_id(remote_files, file)
if entry is None:
raise Exception(
f"get_project_info - Entry for file [{file}] not found in sdcard_3mf_files"
)
pi.id = entry["id"]
pi.name = entry["name"]
pi.plates = plate_nums
pi.plate_num = num
pi.size = entry["size"]
pi.timestamp = entry["timestamp"]
pi.md5 = get_file_md5(localfile)
md = cache_path / "metadata" / f"{filename}-{num}.json"
with md.open("w") as f:
logger.debug(
f"get_project_info - caching 3mf metadata for [{file}] plate [{num}]"
)
json.dump(asdict(pi), f, indent=4)
if pi.plate_num == plate_num:
logger.debug(
f"get_project_info - set return value for [{pi.id}] plate [{pi.plate_num}]"
)
ret = pi
except KeyError as ke:
if printer.config.verbose:
logger.warning(
f"get_project_info - Key Error for [{file}] plate [{num}] - [{ke}]"
)
continue
if not local_file:
localfile.unlink(missing_ok=True)
if ret is not None:
_attach_external_spool_trays(ret.metadata)
return ret