Give an AMS slot a material type, not a product name (issue #2902)

Assigning a spool wrote its material straight into the slot's tray_type.
    A slot that says "PLA+" satisfies nothing that asks for PLA: not
    OrcaSlicer, not Bambu Studio, and not Bambuddy's own dispatch matcher,
    which compares the type the printer reports to the one the 3MF declares
    as plain equality. The reporter's slot was unusable for every PLA plate
    he had.

    Not only a label, either. The same string went into the generic-filament
    lookup, which missed, so the slot went out with no tray_info_idx at all
    -- the half-configured state #2604 documents the printer as reverting
    from -- and took the 200/240 catch-all nozzle range instead of PLA's
    190/230.

    PLA+ is not a special case. Bambuddy's own colour catalogue supplies the
    material dropdown, and around forty of its values are vendor product
    lines rather than filament types: HTPLA, PolyTerra PLA, PLA Matte, ASA
    Extrafill, Flexfill TPU 98A.

    So the four routes that configure a slot reduce the material to a name
    the printer knows before sending it, and the product name moves to
    tray_sub_brands -- which is where Bambu Lab puts it too: their catalogue
    carries a preset named "eSUN PLA+" whose type is PLA. A name the
    reduction cannot place is sent exactly as before rather than guessed at,
    so this can only repair a slot, never break a working one. The spool's
    own wording still leads the id and temperature lookups with the reduced
    type appended behind it, so "PETG HF" keeps its own generic preset
    (GFG96) rather than being traded down to plain PETG's.

    Two guards decide whether a candidate filament id is really a material
    name -- the resolver's, which discards one, and slot reuse, which will
    not carry one forward. Both saw only bare types, so "PLA+" passed as a
    filament id. They share one answer now, which also refuses to read an
    id-shaped value: "GFPLA" ends in a material name, and reducing it would
    throw away the calibrated preset in the slot.

    One thing had to move with it. on_ams_change auto-unlinks an assignment
    whose slot stopped looking the way it did when the spool was assigned,
    and the check that spares a slot Bambuddy itself reconfigured compared
    the printer's reported type against the spool's raw material. With the
    slot now carrying the reduced type, every spool this issue is about
    would have been unlinked from the slot it had just been assigned to.
    Both sides are reduced there -- the printer's too, so slots configured
    by an older version, still reporting "PLA+", keep matching.

    Something starts working as a result: a slot holding a calibrated preset
    is reused when a same-material spool is assigned to it, which could not
    happen for these spools while "PLA" and "PLA+" compared unequal.

    Reverting any one of the behaviours above fails a distinct test -- the
    reduction's four matching rules and its pass-through contract included,
    since that contract is what makes the rest of it safe.
This commit is contained in:
maziggy
2026-08-23 12:44:18 +02:00
10 changed files with 1090 additions and 29 deletions
+1
View File
@@ -15,6 +15,7 @@ All notable changes to Bambuddy will be documented in this file.
- **An archive that arrives with only a name now says what to do about it (#2843, reporter @gyrene2083)** — The no-3MF banner and the connection diagnostic both explained why a print archived with nothing but a name and offered nothing to do about it, and the explanation was wrong in the way that mattered most: both blamed the printer's firmware and said no setting changes it, which reads as "your machine is broken and nothing will help". Measured on hardware: same Bambu Studio, same model, three printers a minute apart, all reporting the external-storage option as on — the H2C and H2D went to internal storage and archived with a name only, the X1C went to the card and archived in full. The same H2C and H2D sliced in OrcaSlicer put the file on the card and archived in full, and turning the option off changed nothing, because OrcaSlicer always uploads over FTPS. So the destination is the slicer's choice, not the printer's, and the option governs neither slicer on this generation. Both strings now name Bambu Studio rather than the firmware and lead with the two routes that take one step — start the print from Bambuddy, or slice in OrcaSlicer — with Send-with-External followed by a separate print start offered as the way to stay in Bambu Studio. Both also mention that a card or stick is still needed, since with an empty slot OrcaSlicer refuses to send at all. Translated in all thirteen locales, and the README callout and this entry now link the upstream issue, bambulab/BambuStudio#10481, so the behaviour can be followed where it is tracked.
### Fixed
- **An AMS slot assigned a PLA+ spool became unusable for PLA (#2902, reported by @doncaruana)** — Assigning a spool wrote its material straight into the slot's `tray_type`, and a slot that says "PLA+" satisfies nothing that asks for PLA: not OrcaSlicer, not Bambu Studio, and not Bambuddy's own dispatch matcher, which compares the printer's reported type to the one the 3MF declares. The same string then missed the filament-id lookup, so the slot went out with no `tray_info_idx` at all — the half-configured state a printer reverts from — and took the generic 200/240°C nozzle range instead of PLA's. PLA+ is not a special case: Bambuddy's own colour catalogue supplies the material dropdown, and about forty of its values are vendor product lines rather than filament types — HTPLA, PolyTerra PLA, PLA Matte, ASA Extrafill, Flexfill TPU 98A. All four routes that configure a slot now reduce the material to a type the printer knows before sending it, and the product name moves to `tray_sub_brands`, which is where Bambu Lab itself puts it — their catalogue has a preset named "eSUN PLA+" whose type is PLA. A material that cannot be placed is sent exactly as before rather than guessed at, so this can only repair a slot, never break a working one; and a material that already had a preset of its own keeps it, so "PETG HF" is not quietly downgraded to plain PETG. One thing that starts working as a result: a slot holding a calibrated preset is now reused when a same-material spool is assigned to it, which for these spools could never happen before.
- **A failed upload told you to check the SD card, whatever had actually gone wrong (#2899, reported by @grolmus)** — Every dispatch upload that failed carried the same sentence: "Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT)." The reporter got it after a TLS handshake failure and restarted the printer on the strength of it. That could not have helped — the handshake never reached the printer's filesystem, and the cool-off that made the next dispatch fail identically lives in Bambuddy's own memory, where power-cycling a printer does not reach. #2780 had already taken operator advice out of this failure's log line, for exactly the reason that the advice was known not to work; it survived in the string people actually read. The information to say something true was never missing. The FTP client separates five connect failures and three upload reply codes, each with its own log line — 553 even gets a spelled-out list of storage causes — and then handed the caller a bare true-or-false, so the dispatch had nothing to go on and guessed storage for all of them. The reason now travels with the result, and the message is chosen from it: a 553 or 552 keeps the card advice, which is the case it was written for, and quotes the printer's reply code so a queue entry and a support bundle can be lined up. A handshake failure says the file service answered without TLS and that the card is not involved. A refused connection points at the access code, a timeout at the network, and anything the client could not classify says so and points at the log rather than picking a plausible cause — a wrong instruction costs more than a vague one, because it sends someone to work on hardware that is fine. No message prescribes a power cycle, which is the restraint #2780 settled on. The failure notification carries the same sentence the queue shows, rather than its own fixed "Failed to upload file to printer", so a push and the screen can no longer disagree.
- **One handshake failure took out three queued jobs and every retry they had (#2898, reported by @grolmus)** — A print dispatch that met a TLS handshake failure spent its whole retry budget without opening a single socket. The cool-off that a failed handshake arms (#2780) lives inside `connect()`, so it applied to everything — including the dispatch, whose four attempts two seconds apart were all answered from the gate rather than the network. The reporter's farm logs the shape exactly: the delete that clears the way for the upload took the SSL error at 11:10:04.956, the upload's first attempt started 8ms later, and attempts two through four took one to two milliseconds each. Because the cool-off runs for five minutes, the next two jobs queued for that printer failed the same way inside the same window, and on that farm the failure is transient — a manual connect a second later completes cleanly — so the retry the gate suppressed is the one that would have worked. The gate was serving two callers that want opposite things from it. The background sweeps that fetch a 3MF, a cover or a timelapse after a print walk about a hundred and ten candidate paths against one wedged printer with nobody waiting, and backing off for minutes is right for them; they keep today's behaviour untouched. A dispatch is one delete plus at most four upload attempts with someone watching a progress bar, so it now ignores the cool-off, as does a firmware upload, for the same reason. Callers that do respect the cool-off no longer sleep out a retry loop against it either: the loop stops at the attempt that armed the gate and says so, instead of spending three more attempts and six seconds on connections that cannot happen. What made this a log dive rather than a glance is fixed with it: the cool-off skip was the one connect failure that reported without naming its cause, and did so at debug level, so four identical reason-free warnings were all the operator saw. It now says at warning level that nothing was sent and how long the printer has left — once per cool-off rather than once per attempt, so that raising it does not recreate the log flood #2780 set out to stop.
- **Archived projects crowded out the live ones in every project picker (#2888, reported by @e77)** — The reporter files each job under its own project and archives it when the job is done, so five active projects sat behind thirty-odd finished ones in the Project dropdown of the Edit Archive dialog — an unscrolled list of everything ever created, with no way to tell which entries were still live. That dropdown, the one on the pending-uploads panel, the bulk "Add to Project" dialog and the File Manager's folder link now leave archived projects out. Completed projects stay: a project marked completed says the work is done, not that it should be hidden, and filing a reprint under one is ordinary. The Archives right-click submenu had gone the other way and offered active projects only, so a completed project was reachable from the Edit dialog and not from the menu beside it; all five surfaces now apply the same rule. Whatever an archive is already filed under stays on its own list whatever its status — a `<select>` holding a value none of its options match is reset by the browser to the first one, which here reads "No project", so an archive in an archived project would have stated that it was filed nowhere. The one picker deliberately left alone is the parent-project picker, where a finished or archived project is still a legal parent. Fixed alongside it: the status tabs on the Projects page counted only the projects the selected filter had already let through, so every tab but the current one counted zero and lost its badge entirely — the reporter's own screenshot shows "Active 5" beside a bare Completed and a bare Archived, with thirty projects behind them. They are counted from the whole fleet now. Covered by frontend tests.
+22 -5
View File
@@ -67,6 +67,7 @@ from backend.app.utils.filament_ids import (
filament_id_to_setting_id,
normalize_slicer_filament,
)
from backend.app.utils.filament_types import is_material_name, printer_filament_type
from backend.app.utils.tag_normalization import normalize_tag_uid, normalize_tray_uuid
logger = logging.getLogger(__name__)
@@ -114,7 +115,10 @@ async def apply_spool_to_slot_via_mqtt(
state = printer_manager.get_status(printer_id)
tray_type = spool.material
# The slot carries the material type; the product line the material column
# may actually hold ("PLA+", "HTPLA") stays in tray_sub_brands below, which
# is where Bambu puts it too (issue #2902).
tray_type = printer_filament_type(spool.material)
tray_sub_brands = (
f"{spool.brand} {spool.material} {spool.subtype}".strip()
if spool.brand
@@ -125,7 +129,6 @@ async def apply_spool_to_slot_via_mqtt(
tray_color = spool.rgba or "FFFFFFFF"
_generic_id_values = _GENERIC_ID_VALUES
_known_materials = set(MATERIAL_TEMPS.keys()) | set(GENERIC_FILAMENT_IDS.keys())
# slicer_filament → (tray_info_idx, setting_id) resolution is shared with
# the Spoolman-mode route via this helper (#1713). The helper handles
@@ -149,16 +152,25 @@ async def apply_spool_to_slot_via_mqtt(
and current_tray_info_idx not in _generic_id_values
and not current_tray_info_idx.startswith("PFUS")
and not current_tray_info_idx.startswith("PFCN")
and current_tray_info_idx.upper() not in _known_materials
# Shares the resolver's reading of what counts as a material
# name, product lines included: a slot written by a Bambuddy from
# before #2902 can be holding "PLA+" in this field, and reusing
# that would carry the bad id forward instead of replacing it.
and not is_material_name(current_tray_info_idx)
and current_tray_type
and current_tray_type.upper() == tray_type.upper()
):
tray_info_idx = current_tray_info_idx
elif tray_type:
material = tray_type.upper().strip()
# The spool's own wording is tried first and the reduced type only
# as a further fallback, so a material that already resolves keeps
# resolving to the same id: "PETG HF" has its own generic preset
# (GFG96) that reducing it to "PETG" would trade away for GFG99.
material = (spool.material or "").upper().strip()
generic = (
GENERIC_FILAMENT_IDS.get(material)
or GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0])
or GENERIC_FILAMENT_IDS.get(tray_type.upper())
or ""
)
if generic:
@@ -172,7 +184,12 @@ async def apply_spool_to_slot_via_mqtt(
if tray_info_idx and not setting_id:
setting_id = filament_id_to_setting_id(tray_info_idx)
temp_min, temp_max = MATERIAL_TEMPS.get((spool.material or "").upper(), (200, 240))
# Same order as the generic-id lookup above: the spool's own wording wins,
# the reduced type rescues what it does not cover. Without the second
# lookup a PLA+ spool took the 200/240 catch-all instead of PLA's 190/230.
temp_min, temp_max = (
MATERIAL_TEMPS.get((spool.material or "").upper()) or MATERIAL_TEMPS.get(tray_type.upper()) or (200, 240)
)
if spool.nozzle_temp_min is not None:
temp_min = spool.nozzle_temp_min
if spool.nozzle_temp_max is not None:
+18 -1
View File
@@ -69,6 +69,7 @@ from backend.app.services.printer_manager import (
uniform_tray_filament_hint,
)
from backend.app.utils.filament_ids import filament_id_to_setting_id
from backend.app.utils.filament_types import printer_filament_type
from backend.app.utils.fts_routing import slot_extruder
from backend.app.utils.http import build_content_disposition
from backend.app.utils.printer_models import MAX_CHAMBER_TEMP_C, uses_exhaust_fan_label
@@ -2557,6 +2558,17 @@ async def configure_ams_slot(
f"[configure_ams_slot] setting_id={setting_id!r}, kprofile_filament_id={kprofile_filament_id!r}, kprofile_setting_id={kprofile_setting_id!r}"
)
# The modal derives tray_type from a preset name or a spool's material, so
# it can be a product line rather than a type ("PLA+", "PolyTerra PLA").
# A slot carrying one of those satisfies nothing that asks for PLA, so the
# slot gets the type and tray_sub_brands -- untouched here -- keeps the
# name (issue #2902). The requested wording is kept for the id lookup
# below, which knows some product lines the type table does not.
requested_tray_type = tray_type
tray_type = printer_filament_type(tray_type)
if tray_type != requested_tray_type:
logger.info("[configure_ams_slot] tray_type %r → %r", requested_tray_type, tray_type)
# Get MQTT client for this printer
client = printer_manager.get_client(printer_id)
if not client:
@@ -2631,10 +2643,15 @@ async def configure_ams_slot(
)
effective_tray_info_idx = current_tray_info_idx
elif tray_type:
material = tray_type.upper().strip()
# Requested wording first, reduced type only as a further fallback,
# so a material that already resolves keeps resolving to the same
# id: "PETG HF" has its own generic preset (GFG96) that reducing it
# to "PETG" would trade away for GFG99.
material = requested_tray_type.upper().strip()
generic = (
_GENERIC_FILAMENT_IDS.get(material)
or _GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0])
or _GENERIC_FILAMENT_IDS.get(tray_type.upper())
or ""
)
if generic:
+20 -6
View File
@@ -35,6 +35,7 @@ from backend.app.utils.filament_ids import (
MATERIAL_TEMPS,
normalize_slicer_filament,
)
from backend.app.utils.filament_types import printer_filament_type
logger = logging.getLogger(__name__)
@@ -904,28 +905,41 @@ async def link_spool(
mqtt_client = printer_manager.get_client(p_id)
if mqtt_client:
tray_type = mapped.get("material") or ""
# Spoolman's material is free text, so it arrives as whatever
# the user typed there -- "PLA+", "PolyTerra PLA". The sub-brand
# keeps that wording; the slot's type has to be one the printer
# and the slicer know (issue #2902).
material = mapped.get("material") or ""
tray_type = printer_filament_type(material)
brand = mapped.get("brand") or ""
subtype = mapped.get("subtype") or ""
if brand:
tray_sub_brands = f"{brand} {tray_type} {subtype}".strip()
tray_sub_brands = f"{brand} {material} {subtype}".strip()
elif subtype:
tray_sub_brands = f"{tray_type} {subtype}".strip()
tray_sub_brands = f"{material} {subtype}".strip()
else:
tray_sub_brands = tray_type
tray_sub_brands = material
tray_color = (mapped.get("rgba") or "808080FF").upper()
if len(tray_color) == 6:
tray_color = tray_color + "FF"
material_upper = tray_type.upper().strip()
# The spool's own wording is tried first and the reduced type
# only as a further fallback, so a material that already
# resolves keeps resolving to the same id: "PETG HF" has its
# own generic preset (GFG96) that reducing it to "PETG" would
# trade away for GFG99.
material_upper = material.upper().strip()
tray_info_idx = (
GENERIC_FILAMENT_IDS.get(material_upper)
or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0])
or GENERIC_FILAMENT_IDS.get(tray_type.upper())
or ""
)
setting_id = ""
temp_defaults = MATERIAL_TEMPS.get(material_upper, (200, 240))
temp_defaults = (
MATERIAL_TEMPS.get(material_upper) or MATERIAL_TEMPS.get(tray_type.upper()) or (200, 240)
)
temp_min = mapped.get("nozzle_temp_min") or temp_defaults[0]
temp_max = temp_defaults[1]
+18 -8
View File
@@ -65,6 +65,7 @@ from backend.app.utils.filament_ids import (
filament_id_to_setting_id,
normalize_slicer_filament,
)
from backend.app.utils.filament_types import printer_filament_type
logger = logging.getLogger(__name__)
@@ -1477,15 +1478,20 @@ async def assign_spoolman_slot(
try:
mqtt_client = printer_manager.get_client(body.printer_id)
if mqtt_client:
tray_type = mapped.get("material") or ""
# Spoolman's material is free text, so it arrives as whatever the
# user typed there -- "PLA+", "PolyTerra PLA". The sub-brand keeps
# that wording; the slot's type has to be one the printer and the
# slicer know (issue #2902).
material = mapped.get("material") or ""
tray_type = printer_filament_type(material)
brand = mapped.get("brand") or ""
subtype = mapped.get("subtype") or ""
if brand:
tray_sub_brands = f"{brand} {tray_type} {subtype}".strip()
tray_sub_brands = f"{brand} {material} {subtype}".strip()
elif subtype:
tray_sub_brands = f"{tray_type} {subtype}".strip()
tray_sub_brands = f"{material} {subtype}".strip()
else:
tray_sub_brands = tray_type
tray_sub_brands = material
tray_color = (mapped.get("rgba") or "808080FF").upper()
if len(tray_color) == 6:
@@ -1504,19 +1510,23 @@ async def assign_spoolman_slot(
current_user=current_user,
slicer_filament=mapped.get("slicer_filament"),
slicer_filament_name=mapped.get("slicer_filament_name"),
material=tray_type,
material=material,
)
if sub_brand_override:
tray_sub_brands = sub_brand_override
material_upper = tray_type.upper().strip()
material_upper = material.upper().strip()
# Fall back to generic-material id when slicer_filament is empty
# or the resolver discarded an unresolvable value. Matches the
# internal-mode tail in inventory.py:_apply_spool_to_slot_inner.
# internal-mode tail in inventory.py:_apply_spool_to_slot_inner,
# including the order: the spool's own wording first and the
# reduced type only after it, so "PETG HF" keeps its own generic
# preset (GFG96) rather than trading it for "PETG"'s GFG99.
if not tray_info_idx:
tray_info_idx = (
GENERIC_FILAMENT_IDS.get(material_upper)
or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0])
or GENERIC_FILAMENT_IDS.get(tray_type.upper())
or ""
)
@@ -1529,7 +1539,7 @@ async def assign_spoolman_slot(
if tray_info_idx and not setting_id:
setting_id = filament_id_to_setting_id(tray_info_idx)
temp_defaults = MATERIAL_TEMPS.get(material_upper, (200, 240))
temp_defaults = MATERIAL_TEMPS.get(material_upper) or MATERIAL_TEMPS.get(tray_type.upper()) or (200, 240)
temp_min = mapped.get("nozzle_temp_min") or temp_defaults[0]
temp_max = temp_defaults[1]
+14 -2
View File
@@ -135,6 +135,7 @@ from backend.app.services.spoolman_tracking import (
)
from backend.app.services.tasmota import tasmota_service
from backend.app.utils.ams_drying import is_drying_active, temperature_alarm_suppressed
from backend.app.utils.filament_types import printer_filament_type
from backend.app.utils.fts_routing import extruder_for_inlet, slot_extruder as resolve_slot_extruder
from backend.app.utils.local_time import utcnow_naive
from backend.app.utils.print_jobs import is_internal_printer_job
@@ -2102,11 +2103,22 @@ async def on_ams_change(printer_id: int, ams_data: list):
continue
# Fingerprint mismatch — but check if tray now matches the
# assigned spool (e.g. auto-configure changed the tray).
# Both sides are reduced to the type the slot can carry
# before comparing: the assign path writes that rather
# than the spool's raw material (#2902), so a spool whose
# material is a product line — "PLA+", "HTPLA" — reports
# back as "PLA" and would otherwise fail this check and
# be auto-unlinked from the slot it was just assigned to.
# Reducing the printer's side too keeps slots configured
# by an older Bambuddy, still reporting "PLA+", matching.
spool = assignment.spool
if spool:
spool_color = (spool.rgba or "FFFFFFFF").upper()
spool_type = (spool.material or "").upper()
if _colors_similar(cur_color, spool_color) and cur_type.upper() == spool_type:
spool_type = printer_filament_type(spool.material).upper()
if (
_colors_similar(cur_color, spool_color)
and printer_filament_type(cur_type).upper() == spool_type
):
logger.info(
"Auto-unlink: spool %d AMS%d-T%d — fingerprint mismatch but tray matches spool, updating fp",
assignment.spool_id,
@@ -42,15 +42,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.user import User
from backend.app.utils.filament_ids import (
GENERIC_FILAMENT_IDS,
MATERIAL_TEMPS,
filament_id_to_setting_id,
normalize_slicer_filament,
)
from backend.app.utils.filament_types import is_material_name
logger = logging.getLogger(__name__)
_KNOWN_MATERIALS = set(MATERIAL_TEMPS.keys()) | set(GENERIC_FILAMENT_IDS.keys())
async def resolve_slicer_filament(
*,
@@ -153,6 +151,12 @@ async def resolve_slicer_filament(
tray_info_idx = lp_filament_id
setting_id = filament_id_to_setting_id(lp_filament_id)
else:
# Deliberately not widened to cover product-line materials
# ("PLA+", "HTPLA") the way the callers' own fallbacks were
# (#2902). Returning an id here rather than "" would skip
# the caller's whole no-id block, and with it the slot-reuse
# branch that keeps a printer's calibrated preset -- so the
# widening belongs there, after reuse has had its turn.
mat = (material or lp.filament_type or "").upper().strip()
tray_info_idx = (
GENERIC_FILAMENT_IDS.get(mat) or GENERIC_FILAMENT_IDS.get(mat.split("-")[0].split(" ")[0]) or ""
@@ -181,7 +185,9 @@ async def resolve_slicer_filament(
# material fallback can rescue the slot:
# 1. Literal material names ("PLA", "PETG-CF") that pass through
# normalize_slicer_filament unchanged when the spool's slicer_filament
# is free-text rather than a real preset ID.
# is free-text rather than a real preset ID. Product lines ("PLA+",
# "HTPLA") count as material names too -- see is_material_name, which
# is shared with the slot-reuse check that must agree with this.
# 2. PFUS-prefix cloud setting_ids — valid as setting_id but rejected
# by the slicer as tray_info_idx (the printer's calibration table
# indexes by filament_id, and a PFUS isn't one). This normally gets
@@ -195,9 +201,7 @@ async def resolve_slicer_filament(
# Valid tray_info_idx values: "GF" + letter + digits (Bambu official) or
# "P" followed by hex (user/local presets, NOT "PFUS" or "PFCN").
if tray_info_idx and (
tray_info_idx.upper() in _KNOWN_MATERIALS
or tray_info_idx.startswith("PFUS")
or tray_info_idx.startswith("PFCN")
is_material_name(tray_info_idx) or tray_info_idx.startswith("PFUS") or tray_info_idx.startswith("PFCN")
):
tray_info_idx = ""
# Preserve setting_id when it's still a valid slicer reference
+170
View File
@@ -15,8 +15,18 @@ which reduces a tray type to a drying-preset key. "Which drying profile?" is a
genuinely different question with a different answer — PLA Silk dries like PLA
but does not print like it — so folding those together would be the wrong kind
of tidy.
Two more readings of a filament type live here for the same reason. Writing one
into an AMS slot needs the name the printer knows rather than the one on the
spool (``printer_filament_type``), and two guards need to agree on when a value
is a material name rather than a preset the printer can resolve
(``is_material_name``). Both were open-coded and inconsistent before #2902.
"""
import re
from backend.app.utils.filament_ids import GENERIC_FILAMENT_IDS, MATERIAL_TEMPS
# Types within a group are interchangeable on the printer side; Bambu Lab
# firmware treats them as the same material. The first entry is canonical.
#
@@ -60,3 +70,163 @@ def canonical_filament_type(ftype: str | None) -> str:
def filament_types_compatible(a: str | None, b: str | None) -> bool:
"""Whether two filament types may stand in for one another."""
return canonical_filament_type(a) == canonical_filament_type(b)
# ---------------------------------------------------------------------------
# Printer-side material names
# ---------------------------------------------------------------------------
# What a Bambu printer and the slicers accept in an AMS slot's ``tray_type``.
# Grounded in the catalogue this repo already carries: every "Generic X" entry
# in ``cloud._BUILTIN_FILAMENT_NAMES`` names a real type, the "Bambu X" entries
# add the composites, and the frontend's ``parsePresetName`` list contributes
# PEEK / PEI / PC-CF / PC-ABS.
#
# Product lines are deliberately absent. "PLA Matte", "PETG HF" and "eSUN PLA+"
# are things you buy, not types the firmware knows -- they belong in
# ``tray_sub_brands``, which is where Bambu itself puts them.
_PRINTER_TYPES: tuple[str, ...] = (
# Order within a length matters for ties: "PLA/PHA" must read as PLA.
"PAHT-CF",
"PA12-CF",
"PETG-CF",
"PPS-CF",
"PPA-CF",
"PPA-GF",
"PLA-CF",
"PA6-CF",
"PA6-GF",
"ABS-GF",
"ASA-CF",
"PET-CF",
"PC-ABS",
"PA-CF",
"PC-CF",
"PP-CF",
"PP-GF",
"PE-CF",
"PCTG",
"PETG",
"BVOH",
"HIPS",
"PEEK",
"PLA",
"PHA",
"ABS",
"ASA",
"TPU",
"PVA",
"PPS",
"EVA",
"PEI",
"PC",
"PA",
"PP",
"PE",
)
# Names that are a material by another name. ``GENERIC_FILAMENT_IDS`` already
# points NYLON and PA at the same generic id, so they are the same thing to
# everything downstream of here.
_TYPE_ALIASES: dict[str, str] = {
"NYLON": "PA",
}
_PRINTER_TYPE_SET = frozenset(_PRINTER_TYPES)
# Longest first, so "PLA-CF" is recognised before the "PLA" inside it. Sorted
# stably, so the declaration order above still decides same-length ties.
_TYPES_LONGEST_FIRST: tuple[str, ...] = tuple(sorted((*_PRINTER_TYPES, *_TYPE_ALIASES), key=len, reverse=True))
# Splits a material name into words while keeping "-" and "+" inside them:
# "PLA-CF" is one word, "PLA/PHA" is two, and the "+" of "PLA+" stays attached
# so the prefix rule below can see a non-letter after the type.
_WORDS = re.compile(r"[^A-Z0-9+\-]+")
def _word_names_type(word: str, candidate: str) -> bool:
"""Whether one word of a material name says "this is a ``candidate``"."""
if word == candidate:
return True
# Two-letter types are too short to recognise inside a longer word. "PA" is
# nylon and "Pastel" is not, and there is no reading of the rules below that
# separates them -- so PA, PC, PE and PP are taken only as a word of their
# own. A spool whose material really is bare nylon still says so.
if len(candidate) < 3:
return False
# A prefix counts only when a non-letter follows it -- "PLA+" and "PETG-HS"
# are the type, "PLASTIC" is a word that merely starts like one.
if word.startswith(candidate) and not word[len(candidate)].isalpha():
return True
# A suffix needs no such guard: "HTPLA" and "rPETG" are how vendors write
# their own PLA and PETG, and nothing else ends in a material name.
return word.endswith(candidate)
def printer_filament_type(material: str | None) -> str:
"""Reduce a spool's material to the name an AMS slot can carry.
Bambuddy lets a spool's material be anything -- typed by hand, synced from
Spoolman, or picked from the colour catalogue, whose material column is the
vendor's product line ("PLA+", "HTPLA", "PolyTerra PLA"). Every assignment
path then wrote that string into the slot's ``tray_type``, and a slot whose
type is "PLA+" satisfies nothing that asks for PLA: not the slicer, and not
Bambuddy's own dispatch matcher, which compares the printer's reported
``tray_type`` to the 3MF's declared type as plain equality (issue #2902).
Returns the material name unchanged when it cannot be placed. That is the
important half of the contract -- an unrecognised material is left exactly
as it arrives rather than guessed at, so this can only ever fix a slot that
was already wrong.
"""
text = (material or "").strip()
if not text:
return ""
upper = text.upper()
if upper in _PRINTER_TYPE_SET:
return upper
if upper in _TYPE_ALIASES:
return _TYPE_ALIASES[upper]
words = [w for w in _WORDS.split(upper) if w]
for candidate in _TYPES_LONGEST_FIRST:
if any(_word_names_type(w, candidate) for w in words):
return _TYPE_ALIASES.get(candidate, candidate)
return text
# Both tables are keyed by material, so their keys are exactly the set of names
# that are a material rather than a preset the printer can resolve.
_MATERIAL_NAMES = frozenset(MATERIAL_TEMPS) | frozenset(GENERIC_FILAMENT_IDS)
# "GF" + letter + digits for Bambu's own presets, "P" + hex for local and cloud
# ones -- the shapes ``slicer_filament_resolver`` documents. Matched loosely on
# purpose: this only ever has to be sure a value is NOT a bare material name.
_PRESET_ID_SHAPE = re.compile(r"^(?:GF|P)[A-Za-z0-9_]*$")
def is_material_name(value: str | None) -> bool:
"""Whether a candidate filament id is really just a material name.
Two places ask this, and have to answer it the same way: the slicer-filament
resolver, which throws such a value away so its caller's generic fallback
can rescue the slot, and the slot-reuse check, which will not carry one
forward. Both compared against the table above and so saw only bare types --
but "PLA+" is exactly as unusable a filament id as the "PLA" they already
rejected, and reaching the printer is how a product line ended up in the
field the calibration table is keyed by (issue #2902).
A value shaped like a preset id is never a material name, whatever letters
it happens to end in. Reading "GFPLA" as PLA would discard it, and what the
user loses when that happens is the calibrated preset in the slot.
"""
text = (value or "").strip()
if not text:
return False
if text.upper() in _MATERIAL_NAMES:
return True
if _PRESET_ID_SHAPE.match(text):
return False
return printer_filament_type(text).upper() in _MATERIAL_NAMES
@@ -0,0 +1,625 @@
"""What reaches an AMS slot when a spool's material is a product line (#2902).
The reporter assigned an eSUN PLA+ spool and the slot came out unusable: any
plate sliced with a PLA profile refused it. Four routes configure a slot and
all four wrote the spool's material straight into ``tray_type``, where "PLA+"
matches nothing -- not the slicer, and not Bambuddy's own dispatch matcher,
which compares the printer's reported ``tray_type`` to the 3MF's declared type
as plain equality.
Each test below asserts the whole slot, not just the type: an unrecognised
material also missed the generic-filament-id lookup, so the slot went out with
an empty ``tray_info_idx`` -- the half-configured state #2604 documents the
printer as reverting from -- and took the 200/240 catch-all temperatures
instead of PLA's.
"""
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.spool import Spool
def _mqtt_mock():
client = MagicMock()
client.ams_set_filament_setting.return_value = True
client.extrusion_cali_sel.return_value = True
return client
def _status(ams_data=None):
status = MagicMock()
status.raw_data = {"ams": {"ams": ams_data if ams_data is not None else []}}
status.nozzles = [MagicMock(nozzle_diameter="0.4")]
status.ams_extruder_map = None
status.kprofiles = []
return status
def _spoolman_spool(material, spool_id=11, slicer_filament=None):
extra = {}
if slicer_filament is not None:
extra["bambu_slicer_filament"] = json.dumps(str(slicer_filament))
return {
"id": spool_id,
"filament": {
"id": 1,
"name": "Cool White",
"material": material,
"color_hex": "E1E9E9",
"weight": 1000,
"vendor": {"id": 1, "name": "eSUN"},
},
"remaining_weight": 800.0,
"used_weight": 200.0,
"archived": False,
"extra": extra,
}
def _spoolman_client(spool):
client = MagicMock()
client.base_url = "http://localhost:7912"
client.health_check = AsyncMock(return_value=True)
client.get_spool = AsyncMock(return_value=spool)
client.get_spools = AsyncMock(return_value=[spool])
client.merge_spool_extra = AsyncMock(return_value=spool)
return client
class TestInternalInventoryAssign:
async def _assign(self, async_client, db_session, material, **spool_kwargs):
from backend.app.models.printer import Printer
printer = Printer(
name="P1S",
serial_number=f"MAT2902{material[:4]}",
ip_address="192.168.1.77",
access_code="12345678",
)
db_session.add(printer)
spool = Spool(
material=material,
brand="eSUN",
color_name="Cool White",
rgba="E1E9E9FF",
label_weight=1000,
weight_used=0,
**spool_kwargs,
)
db_session.add(spool)
await db_session.commit()
await db_session.refresh(printer)
await db_session.refresh(spool)
client = _mqtt_mock()
with patch("backend.app.services.printer_manager.printer_manager") as pm:
pm.get_client.return_value = client
pm.get_status.return_value = _status()
response = await async_client.post(
"/api/v1/inventory/assignments",
json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
)
assert response.status_code == 200
client.ams_set_filament_setting.assert_called_once()
return client.ams_set_filament_setting.call_args.kwargs
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_pla_plus_spool_configures_the_slot_as_pla(
self, async_client: AsyncClient, db_session: AsyncSession
):
sent = await self._assign(async_client, db_session, "PLA+")
assert sent["tray_type"] == "PLA"
# Not just the label: the id and its setting_id are what stop the
# printer treating the slot as half configured, and the temperatures
# are PLA's rather than the catch-all.
assert sent["tray_info_idx"] == "GFL99"
assert sent["setting_id"] == "GFSL99"
assert (sent["nozzle_temp_min"], sent["nozzle_temp_max"]) == (190, 230)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_the_product_name_is_not_lost_it_moves_to_the_sub_brand(
self, async_client: AsyncClient, db_session: AsyncSession
):
"""Which is where Bambu itself puts it -- their own catalogue has a
preset named "eSUN PLA+" (GFL03) whose type is PLA."""
sent = await self._assign(async_client, db_session, "PLA+")
assert "PLA+" in sent["tray_sub_brands"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_material_that_already_resolved_keeps_its_own_preset(
self, async_client: AsyncClient, db_session: AsyncSession
):
""" "PETG HF" has a generic preset of its own (GFG96, "Generic PETG HF").
Reducing the material before the id lookup rather than after it would
trade that away for plain PETG's GFG99 -- a quiet downgrade of slots
that work today."""
sent = await self._assign(async_client, db_session, "PETG HF")
assert sent["tray_info_idx"] == "GFG96"
assert sent["tray_type"] == "PETG"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_it_can_now_reuse_the_calibrated_preset_already_in_the_slot(
self, async_client: AsyncClient, db_session: AsyncSession
):
"""A slot already holding a specific preset keeps it when the incoming
spool is the same material -- that is how a printer's calibration
context survives an assignment. The comparison is against the slot's
reported type, so a PLA+ spool could never match a PLA slot and the
reuse branch was dead for every spool this issue is about."""
from backend.app.models.printer import Printer
printer = Printer(
name="Reuse P1S",
serial_number="MAT2902RU",
ip_address="192.168.1.81",
access_code="12345678",
)
db_session.add(printer)
spool = Spool(material="PLA+", brand="eSUN", rgba="E1E9E9FF", label_weight=1000, weight_used=0)
db_session.add(spool)
await db_session.commit()
await db_session.refresh(printer)
await db_session.refresh(spool)
client = _mqtt_mock()
live_slot = [{"id": 0, "tray": [{"id": 1, "tray_info_idx": "P4d64437", "tray_type": "PLA"}]}]
with patch("backend.app.services.printer_manager.printer_manager") as pm:
pm.get_client.return_value = client
pm.get_status.return_value = _status(live_slot)
response = await async_client.post(
"/api/v1/inventory/assignments",
json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
)
assert response.status_code == 200
sent = client.ams_set_filament_setting.call_args.kwargs
assert sent["tray_info_idx"] == "P4d64437"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_but_it_does_not_reuse_a_product_name_a_previous_version_left_there(
self, async_client: AsyncClient, db_session: AsyncSession
):
"""A spool whose slicer_filament was free text could send that text to
the printer as the slot's filament id, and the printer reports it
straight back -- so an upgraded install can be looking at a slot that
says type PLA, id "PLA+". Reuse has always refused a bare material name
in that field; refusing a product line too is what stops the bad id
being carried forward on every assignment instead of replaced."""
from backend.app.models.printer import Printer
printer = Printer(
name="Stale P1S",
serial_number="MAT2902ST",
ip_address="192.168.1.82",
access_code="12345678",
)
db_session.add(printer)
spool = Spool(material="PLA", brand="eSUN", rgba="E1E9E9FF", label_weight=1000, weight_used=0)
db_session.add(spool)
await db_session.commit()
await db_session.refresh(printer)
await db_session.refresh(spool)
client = _mqtt_mock()
stale_slot = [{"id": 0, "tray": [{"id": 1, "tray_info_idx": "PLA+", "tray_type": "PLA"}]}]
with patch("backend.app.services.printer_manager.printer_manager") as pm:
pm.get_client.return_value = client
pm.get_status.return_value = _status(stale_slot)
response = await async_client.post(
"/api/v1/inventory/assignments",
json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
)
assert response.status_code == 200
sent = client.ams_set_filament_setting.call_args.kwargs
assert sent["tray_info_idx"] == "GFL99"
assert sent["tray_type"] == "PLA"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_material_nothing_can_be_made_of_is_sent_unchanged(
self, async_client: AsyncClient, db_session: AsyncSession
):
"""The catalogue ships a few names with no filament type in them at all.
Guessing at those would be worse than leaving them: this route behaved
exactly this way before #2902, and still does."""
sent = await self._assign(async_client, db_session, "CPE HG100")
assert sent["tray_type"] == "CPE HG100"
assert sent["tray_info_idx"] == ""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_free_text_slicer_filament_naming_a_product_is_not_a_filament_id(
self, async_client: AsyncClient, db_session: AsyncSession
):
"""slicer_filament is free text on older spools, so "PLA+" can be sitting
in it. It is as unusable a tray_info_idx as the bare "PLA" the resolver
already discarded, and letting it through would put a product name in
the field the printer keys its calibration table by."""
sent = await self._assign(async_client, db_session, "PLA+", slicer_filament="PLA+")
assert sent["tray_info_idx"] == "GFL99"
class TestSpoolmanInventoryAssign:
@pytest.fixture
async def settings(self, db_session):
from backend.app.models.settings import Settings
db_session.add(Settings(key="spoolman_enabled", value="true"))
db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
await db_session.commit()
@pytest.fixture
async def printer(self, db_session):
from backend.app.models.printer import Printer
p = Printer(
name="Spoolman P1S",
serial_number="MAT2902SM",
ip_address="192.168.1.78",
access_code="12345678",
)
db_session.add(p)
await db_session.commit()
await db_session.refresh(p)
return p
async def _assign(self, async_client, printer, material, slicer_filament=None):
mqtt = _mqtt_mock()
spool = _spoolman_spool(material, slicer_filament=slicer_filament)
with (
patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm,
patch(
"backend.app.api.routes.spoolman_inventory.get_spoolman_client",
AsyncMock(return_value=_spoolman_client(spool)),
),
):
pm.get_client.return_value = mqtt
pm.get_status.return_value = _status()
response = await async_client.post(
"/api/v1/spoolman/inventory/slot-assignments",
json={"spoolman_spool_id": 11, "printer_id": printer.id, "ams_id": 0, "tray_id": 2},
)
assert response.status_code == 200
return mqtt.ams_set_filament_setting.call_args.kwargs
@pytest.mark.asyncio
@pytest.mark.integration
async def test_spoolmans_free_text_material_is_reduced_the_same_way(
self, async_client: AsyncClient, settings, printer
):
"""Spoolman's material field is free text too, so the same product names
arrive by this route -- and it is the route the reporter used."""
sent = await self._assign(async_client, printer, "PLA+")
assert sent["tray_type"] == "PLA"
assert sent["tray_info_idx"] == "GFL99"
# Exactly, not just "contains PLA+": the filament's own name is in this
# string too, so a substring check would pass even if the material had
# been reduced before it was built.
assert sent["tray_sub_brands"] == "eSUN PLA+ Cool White"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_it_keeps_this_routes_own_preset_for_a_material_that_had_one(
self, async_client: AsyncClient, settings, printer
):
sent = await self._assign(async_client, printer, "PETG HF")
assert sent["tray_info_idx"] == "GFG96"
assert sent["tray_type"] == "PETG"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_the_resolver_is_handed_the_spools_own_wording_not_the_type(
self, async_client: AsyncClient, settings, printer, db_session
):
"""This route also passes the material down to the slicer-filament
resolver. Handing that the reduced type instead would look harmless and
quietly downgrade GFG96 to GFG99 whenever the spool points at a local
preset with no filament_id of its own."""
from backend.app.models.local_preset import LocalPreset
lp = LocalPreset(name="Generic PETG HF", preset_type="filament", source="orcaslicer", setting="{}")
db_session.add(lp)
await db_session.commit()
await db_session.refresh(lp)
sent = await self._assign(async_client, printer, "PETG HF", slicer_filament=lp.id)
assert sent["tray_info_idx"] == "GFG96"
class TestConfigureSlotModal:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_product_line_typed_into_the_modal_is_reduced_too(self, async_client: AsyncClient, printer_factory):
"""The Configure Slot modal derives tray_type from a preset name or the
spool's material, so it can hand the backend a product line as readily
as the assignment routes can."""
printer = await printer_factory(model="P1S")
client = _mqtt_mock()
with patch("backend.app.api.routes.printers.printer_manager") as pm:
pm.get_client.return_value = client
pm.get_status.return_value = _status()
response = await async_client.post(
f"/api/v1/printers/{printer.id}/slots/0/1/configure",
params={
"tray_info_idx": "",
"tray_type": "PLA+",
"tray_sub_brands": "eSUN PLA+",
"tray_color": "E1E9E9FF",
"nozzle_temp_min": 190,
"nozzle_temp_max": 230,
},
)
assert response.status_code == 200
sent = client.ams_set_filament_setting.call_args.kwargs
assert sent["tray_type"] == "PLA"
# The empty tray_info_idx the modal sent for a generic material is what
# the reduced type now rescues.
assert sent["tray_info_idx"] == "GFL99"
assert sent["tray_sub_brands"] == "eSUN PLA+"
class TestSpoolmanLink:
"""The fourth route that configures a slot: linking a Spoolman spool to a
slot's tag auto-configures it too, from the same free-text material."""
@pytest.fixture
async def settings(self, db_session):
from backend.app.models.settings import Settings
db_session.add(Settings(key="spoolman_enabled", value="true"))
db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
await db_session.commit()
@pytest.fixture
async def printer(self, db_session):
from backend.app.models.printer import Printer
p = Printer(
name="Link P1S",
serial_number="MAT2902LK",
ip_address="192.168.1.79",
access_code="12345678",
)
db_session.add(p)
await db_session.commit()
await db_session.refresh(p)
return p
async def _link(self, async_client, printer, material):
client = _spoolman_client(_spoolman_spool(material, spool_id=12))
mqtt = _mqtt_mock()
with (
patch("backend.app.api.routes.spoolman.get_spoolman_client", AsyncMock(return_value=client)),
patch("backend.app.api.routes.spoolman.init_spoolman_client", AsyncMock(return_value=client)),
patch("backend.app.api.routes.spoolman.printer_manager") as pm,
):
pm.get_client.return_value = mqtt
pm.get_status.return_value = _status()
response = await async_client.post(
"/api/v1/spoolman/spools/12/link",
json={
"tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
"printer_id": printer.id,
"ams_id": 0,
"tray_id": 3,
},
)
assert response.status_code == 200
return mqtt.ams_set_filament_setting.call_args.kwargs
@pytest.mark.asyncio
@pytest.mark.integration
async def test_linking_a_pla_plus_spool_configures_the_slot_as_pla(
self, async_client: AsyncClient, settings, printer
):
sent = await self._link(async_client, printer, "PLA+")
assert sent["tray_type"] == "PLA"
assert sent["tray_info_idx"] == "GFL99"
assert sent["tray_sub_brands"] == "eSUN PLA+ Cool White"
assert (sent["nozzle_temp_min"], sent["nozzle_temp_max"]) == (190, 230)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_it_keeps_this_routes_own_preset_for_a_material_that_had_one(
self, async_client: AsyncClient, settings, printer
):
sent = await self._link(async_client, printer, "PETG HF")
assert sent["tray_info_idx"] == "GFG96"
assert sent["tray_type"] == "PETG"
class TestALocalPresetThatNamesNoFilamentId:
"""The one path where the material reaches the slicer-filament resolver
rather than the route's own fallback: a spool pointing at an imported local
preset whose setting JSON carries no filament_id. The resolver then has only
the material to go on, so it has to read it the same way -- and be handed
the spool's own wording, not the reduced type."""
@pytest.fixture
async def preset(self, db_session):
from backend.app.models.local_preset import LocalPreset
lp = LocalPreset(
name="eSUN PLA+ @BBL P1S",
preset_type="filament",
source="orcaslicer",
filament_type=None,
setting="{}",
)
db_session.add(lp)
await db_session.commit()
await db_session.refresh(lp)
return lp
@pytest.fixture
async def printer(self, db_session):
from backend.app.models.printer import Printer
p = Printer(
name="LP P1S",
serial_number="MAT2902LP",
ip_address="192.168.1.80",
access_code="12345678",
)
db_session.add(p)
await db_session.commit()
await db_session.refresh(p)
return p
async def _assign(self, async_client, db_session, printer, preset, material):
spool = Spool(
material=material,
brand="eSUN",
rgba="E1E9E9FF",
label_weight=1000,
weight_used=0,
slicer_filament=str(preset.id),
)
db_session.add(spool)
await db_session.commit()
await db_session.refresh(spool)
client = _mqtt_mock()
with patch("backend.app.services.printer_manager.printer_manager") as pm:
pm.get_client.return_value = client
pm.get_status.return_value = _status()
response = await async_client.post(
"/api/v1/inventory/assignments",
json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
)
assert response.status_code == 200
return client.ams_set_filament_setting.call_args.kwargs
@pytest.mark.asyncio
@pytest.mark.integration
async def test_the_resolver_places_a_product_line_too(
self, async_client: AsyncClient, db_session: AsyncSession, printer, preset
):
sent = await self._assign(async_client, db_session, printer, preset, "PLA+")
assert sent["tray_info_idx"] == "GFL99"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_and_still_prefers_a_material_that_has_its_own_preset(
self, async_client: AsyncClient, db_session: AsyncSession, printer, preset
):
sent = await self._assign(async_client, db_session, printer, preset, "PETG HF")
assert sent["tray_info_idx"] == "GFG96"
class TestTheAssignmentSurvivesTheSlotItJustConfigured:
"""The other side of the same coin, and the one that bites hardest.
on_ams_change auto-unlinks an assignment whose slot no longer looks like it
did when the spool was assigned. The fingerprint is snapshotted *before* the
MQTT config goes out, so the very next AMS push after an assignment is a
mismatch by construction -- and what saves the assignment is a second check:
does the tray match the assigned spool now? That check read the spool's raw
material, which the slot no longer carries, so every spool this issue is
about would have been silently unlinked from the slot it had just been
assigned to. Correct in isolation, ruinous together.
"""
async def _push(self, db_session, printer_factory, spool_material, reported_type, fingerprint_type="PETG"):
from unittest.mock import AsyncMock
from backend.app.main import on_ams_change
from backend.app.models.spool_assignment import SpoolAssignment
printer = await printer_factory(name="H2D")
spool = Spool(material=spool_material, brand="eSUN", rgba="E1E9E9FF", label_weight=1000, weight_used=0)
db_session.add(spool)
await db_session.commit()
await db_session.refresh(spool)
assignment = SpoolAssignment(
spool_id=spool.id,
printer_id=printer.id,
ams_id=0,
tray_id=2,
fingerprint_color="E1E9E9FF",
fingerprint_type=fingerprint_type,
)
db_session.add(assignment)
await db_session.commit()
assignment_id = assignment.id
ams_data = [{"id": 0, "tray": [{"id": 2, "tray_type": reported_type, "tray_color": "E1E9E9FF", "state": 11}]}]
status = _status(ams_data)
status.state = "IDLE"
with (
patch("backend.app.main.printer_manager") as pm,
patch("backend.app.main.mqtt_relay") as relay,
patch("backend.app.main.ws_manager") as ws,
):
pm.get_printer.return_value = MagicMock(name="H2D", serial_number="0948BB540200427")
pm.get_status.return_value = status
pm.get_model.return_value = "H2D"
relay.on_ams_change = AsyncMock()
ws.send_printer_status = AsyncMock()
ws.broadcast = AsyncMock()
await on_ams_change(printer.id, ams_data)
# on_ams_change commits through its own session.
db_session.expunge_all()
return await db_session.get(SpoolAssignment, assignment_id)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_pla_plus_spool_is_not_unlinked_from_the_slot_now_reporting_pla(
self, async_client: AsyncClient, db_session: AsyncSession, printer_factory
):
surviving = await self._push(db_session, printer_factory, "PLA+", reported_type="PLA")
assert surviving is not None, "the slot reports what we wrote to it -- that is a match, not a swap"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_nor_is_one_in_a_slot_an_older_version_configured(
self, async_client: AsyncClient, db_session: AsyncSession, printer_factory
):
"""An install upgrading into this fix has slots still reporting "PLA+"
until something reconfigures them. Reducing only the spool's side would
break those the moment they were left alone."""
surviving = await self._push(db_session, printer_factory, "PLA+", reported_type="PLA+")
assert surviving is not None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_genuinely_different_filament_still_unlinks(
self, async_client: AsyncClient, db_session: AsyncSession, printer_factory
):
"""The check still has to do its job: someone swapping PLA for ABS in
the slot must lose the assignment, or usage gets charged to the wrong
spool."""
surviving = await self._push(db_session, printer_factory, "PLA+", reported_type="ABS")
assert surviving is None
@@ -0,0 +1,191 @@
"""What a spool's material becomes when it is written into an AMS slot (#2902).
The reporter had eSUN PLA+ in his inventory. Bambuddy wrote "PLA+" into the
slot's ``tray_type``, and neither slicer has such a type -- so a plate sliced
with any PLA profile could not use that slot. The same string also went into
the generic-filament-id lookup, which missed, so the slot went out with an
empty ``tray_info_idx`` on top.
"PLA+" is not a special case. The bundled colour catalogue's material column
is the vendor's product line, and roughly forty of its values are not filament
types: HTPLA, PolyTerra PLA, PLA Matte, ASA Extrafill, Flexfill TPU 98A.
"""
import pytest
from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG
from backend.app.utils.filament_ids import filament_id_to_setting_id
from backend.app.utils.filament_types import (
_PRINTER_TYPES,
is_material_name,
printer_filament_type,
)
class TestTheReportedCase:
def test_pla_plus_is_pla(self):
assert printer_filament_type("PLA+") == "PLA"
def test_so_is_every_other_way_a_vendor_writes_pla(self):
# All four are shipped catalogue values, so all four could be sitting in
# someone's material field right now.
assert printer_filament_type("HTPLA") == "PLA"
assert printer_filament_type("PolyTerra PLA") == "PLA"
assert printer_filament_type("PLA Matte") == "PLA"
assert printer_filament_type("Pro PLA+") == "PLA"
class TestItLeavesAlonePreciselyWhatItCannotPlace:
"""The load-bearing half of the contract. Anything this cannot recognise has
to come back untouched, because that is what the four assignment paths sent
before -- so the change can only ever fix a slot, never break a working one.
"""
@pytest.mark.parametrize("unknown", ["CPE HG100", "FiberSilk Metallic", "XT", "NylonX"])
def test_an_unrecognised_material_survives_verbatim(self, unknown):
assert printer_filament_type(unknown) == unknown
@pytest.mark.parametrize(
"preset_id",
[
"GFL99", # Bambu generic
"GFSL99", # its setting_id form
"GFA01", # Bambu official
"P4d64437", # local user preset
"PFUS9ac902733670a9", # cloud user preset
"PFCN0123abcd", # cloud shared preset
],
)
def test_a_filament_id_is_never_mistaken_for_a_material(self, preset_id):
"""slicer_filament_resolver runs candidate tray_info_idx values through
this to decide whether they are really a material name in disguise. A
preset id that came back as "PA" would be discarded as junk and the slot
would silently lose the user's calibrated profile."""
assert printer_filament_type(preset_id) == preset_id
def test_a_word_that_merely_starts_like_a_type_is_not_that_type(self):
assert printer_filament_type("PLASTIC") == "PLASTIC"
@pytest.mark.parametrize("name", ["Pastel", "Sparkle", "Pearl"])
def test_two_letter_types_are_not_hunted_inside_words(self, name):
""" "PA" is nylon and "Pastel" is not. There is no rule that separates
them by shape, so PA/PC/PE/PP are only ever read as a word of their own."""
assert printer_filament_type(name) == name
def test_but_a_spool_that_really_is_bare_nylon_still_says_so(self):
assert printer_filament_type("PA") == "PA"
assert printer_filament_type("PC") == "PC"
assert printer_filament_type("Nylon") == "PA"
class TestHowItReadsAName:
def test_a_type_at_the_end_of_a_word_counts(self):
# How vendors write their own: Protopasta's HTPLA, Filamentum's rPETG.
assert printer_filament_type("HTPLA") == "PLA"
assert printer_filament_type("rPETG") == "PETG"
assert printer_filament_type("ReForm rPLA") == "PLA"
def test_a_type_at_the_start_counts_only_when_a_non_letter_follows(self):
assert printer_filament_type("PLA+") == "PLA"
assert printer_filament_type("PETG-HS") == "PETG"
assert printer_filament_type("PLA-ST") == "PLA"
assert printer_filament_type("PLASTIC") == "PLASTIC"
def test_the_longest_type_wins(self):
"""Otherwise a carbon-filled spool would be configured as plain PLA and
print at the wrong temperature."""
assert printer_filament_type("Hyper PLA-CF") == "PLA-CF"
assert printer_filament_type("PLA-CF") == "PLA-CF"
assert printer_filament_type("PAHT-CF") == "PAHT-CF"
def test_a_blend_reads_as_its_first_named_type(self):
# PLA/PHA prints as PLA, and PLA is the half a slicer profile targets.
assert printer_filament_type("PLA/PHA") == "PLA"
def test_case_and_padding_do_not_matter(self):
assert printer_filament_type("pla") == "PLA"
assert printer_filament_type(" PLA ") == "PLA"
assert printer_filament_type("pLa MaTtE") == "PLA"
@pytest.mark.parametrize("empty", [None, "", " "])
def test_nothing_in_nothing_out(self, empty):
assert printer_filament_type(empty) == ""
class TestTheShippedCatalogue:
"""The catalogue is where the reporter's PLA+ came from, so it is also the
honest test set: every material Bambuddy itself puts in front of a user."""
def test_every_catalogue_material_lands_on_a_type_or_is_left_alone(self):
# Names that carry no filament type at all. Nothing can be made of them,
# so they must pass through -- exactly as they did before #2902.
unplaceable = {"CPE HG100", "FiberSilk Metallic", "XT", "NylonX", "NylonG"}
known = set(_PRINTER_TYPES)
for entry in DEFAULT_COLOR_CATALOG:
material = entry[3]
result = printer_filament_type(material)
if material in unplaceable:
assert result == material, f"{material!r} should have been left alone"
else:
assert result in known, f"{material!r} reduced to {result!r}, which is not a filament type"
def test_the_ones_that_cannot_be_placed_are_still_only_those_five(self):
"""A guard on the list above: if a catalogue sync adds a sixth, this
fails and someone gets to decide what it is, rather than a slot quietly
going out with a product name in it again."""
known = set(_PRINTER_TYPES)
leftover = {e[3] for e in DEFAULT_COLOR_CATALOG if printer_filament_type(e[3]) not in known}
assert leftover == {"CPE HG100", "FiberSilk Metallic", "XT", "NylonX", "NylonG"}
class TestIsMaterialName:
"""The question the slicer-filament resolver and the slot-reuse check both
ask of a candidate filament id, and have to answer the same way."""
@pytest.mark.parametrize(
"name",
["PLA", "PETG", "PA-CF", "PETG HF", "NYLON", " PLA ", "PLA+", "HTPLA", "PolyTerra PLA", "Pro PLA+"],
)
def test_a_material_or_a_product_line_is_one(self, name):
assert is_material_name(name) is True
@pytest.mark.parametrize(
"preset_id",
[
"GFL99",
"GFA01",
"GFSL05_07",
"GFNC0",
"P4d64437",
"PFUS9ac902733670a9",
"PFCN0123abcd",
# The trap this guard exists for. Bambu ids carry letters in these
# positions (GFNC0 does), so an id that happens to end in a material
# name is not far-fetched -- and reading it as one would throw the
# user's calibrated preset out of the slot.
"GFPLA",
"GFSABS",
"GFTPU",
"GFPVA",
"GFASA",
],
)
def test_a_preset_id_never_is(self, preset_id):
assert is_material_name(preset_id) is False
def test_no_shipped_bambu_id_reads_as_a_material(self):
"""The exhaustive version of the case above, over the whole catalogue
this repo knows, in both the filament_id and setting_id spellings."""
from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
for fid in _BUILTIN_FILAMENT_NAMES:
assert not is_material_name(fid), fid
assert not is_material_name(filament_id_to_setting_id(fid)), fid
@pytest.mark.parametrize("unknown", ["PCTG", "PPS-CF", "CPE HG100", "XT", "", None])
def test_anything_it_cannot_place_is_left_for_the_caller_to_use(self, unknown):
"""False means "keep it". A type the tables do not carry is not proof
the value is junk, and discarding it would empty the slot's filament id
for no reason."""
assert is_material_name(unknown) is False