fix(ams): resolve a custom filament's own id from every preset source (issue #3003)

A custom filament profile reaches an AMS slot as itself through exactly one
field, tray_info_idx, and every source we can read that id from was reading it
from the wrong place or not reading it at all.

Bambu Cloud returns a preset's own filament_id either on the response envelope
or inside the preset JSON under `setting`, and only the envelope was read.
Presets of the second shape fell through to the base_id branch and reached the
slicer as the Bambu filament they inherit from. filament_type next door already
handled both spreads; filament_id now does too.

Orca Cloud was absent from the resolver entirely. A spool stores the bare
profile UUID, which matched no branch and fell through normalize_slicer_filament
-- a function that passes anything it does not recognise straight through -- so
a 36-character UUID went into the field. Orca profiles carry their own
filament_id in the slicer JSON that OrcaProfileDetail already exposes under
`setting`, so the lookup is the same one the Bambu branch does. It is
best-effort: no pairing, a dead token or a missing orca_cloud:auth permission
degrades to the fallback rather than failing the assignment, and it passes
clear_on_auth_failure=False because a background caller cannot tell a real
revocation from a lost refresh-rotation race.

configure_ams_slot sent the cloud setting_id as tray_info_idx when it found no
real filament id. That field is 8 characters on the printer -- exactly the width
of a local preset id, less than half a cloud one. Measured on the reporter's A1:
sent PFUS9ddc938fe3ab8f, the tray read back PFUS9DDC, acknowledged as a success.
The slot then resolved to nothing, so the slicer showed Generic anyway and the
calibration table, keyed by the same field, lost the slot. It now falls back to
the slot's existing filament id or the generic for the material, and the route's
guard was aligned with the resolver's so both refuse the same four shapes from
one shared definition.

This reverses the contract #1053 pinned. Six tests asserted that the PFUS
belonged in tray_info_idx; the A1 capture shows it never worked, so they were
rewritten with the measurement in their docstrings.

Verified against 874 AMS trays across twelve models in the support archive: 92
already carry a custom "P" + 7 hex filament id, which is what confirms the
mechanism works and this is a lookup failure rather than a platform limit. No
tray on any model carries a setting_id, so a profile with no filament_id of its
own still cannot be told apart from its base.
This commit is contained in:
maziggy
2026-09-07 10:15:06 +02:00
parent 5ab0a4cb11
commit 9434875fa1
9 changed files with 645 additions and 49 deletions
+1
View File
@@ -28,6 +28,7 @@ All notable changes to Bambuddy will be documented in this file.
- **The Windows installer build is split in two so a signing request can wait for a human (SignPath Foundation)** — Release tags are Authenticode-signed through the SignPath Foundation OSS programme, and the production certificate does not sign on demand the way the self-signed test certificate does: every request has to be approved by hand in the SignPath UI, because the Foundation verifies what is being signed and which build it came from. The submitting action waits for that approval with a default timeout of 600 seconds, which is ample when the test policy approves automatically in seconds and far too short once the wait is a person noticing a tag went out. A tag pushed at night would have failed the run ten minutes later with the installer already compiled and thrown away. The compile now ends in its own job that uploads the unsigned artifact and stops; a second job downloads it, signs it, and does the release-facing work, with the wait raised to an hour. Because the artifact is uploaded before the wait begins and is addressed by id, a missed approval window is recovered by re-running the second job alone rather than rebuilding the installer — which is the reason to separate them rather than simply raise the timeout in place. The second job runs for unsigned builds too, so the daily prereleases that are deliberately left unsigned to preserve the signing quota keep going out through exactly one set of alias, artifact and release steps. The property that matters is unchanged and now recorded next to the steps that depend on it: none of the alias, upload or release-attach steps carry `always()`, so GitHub skips all three when signing fails or times out, and an unsigned `.exe` cannot reach a release. Nothing about the signed output changes, and the restructure behaves identically under the test policy — the request simply completes immediately instead of waiting — so it can be proven green before the production certificate arrives.
### Fixed
- **Custom filament profiles arrived in the slicer as Generic, or as the Bambu profile they were built on (#3003, reported by @marivo)** — a custom profile reaches an AMS slot as itself through exactly one field, the slot's filament id, and every source Bambuddy can get that id from was reading it from the wrong place or not at all. Bambu Cloud reports it in either of two spots depending on the preset and only one was read, so presets of the other shape fell back to the id of the Bambu filament they inherit from — which is what the slicer then showed. Orca Cloud profiles were never looked up at all: their ids are UUIDs the printer cannot store, so the code went straight to a generic without ever asking whether the profile had a usable id of its own, which it normally does. And the Configure AMS Slot dialog, when it could not find a real id, sent the preset's own identifier instead — that field holds eight characters, less than half an identifier, so it was stored truncated and acknowledged as a success, leaving the slot pointing at nothing and the printer's calibration table, keyed by the same field, without a slot to key. All four sources are now tried in order — Orca Cloud, Bambu Cloud, imported local profiles, then a generic for the material — and nothing the printer cannot store is sent. A profile that genuinely has no filament id of its own still cannot be told apart from the one it inherits from; there is nowhere else in what a printer publishes to put it.
- **Hovering a muted control in the light theme made its label vanish (#1909, reported by @AntonPalmqvist)** — the tab strip on the Print Queue page was where it got noticed: point at **Batches** and the word turned white on a near-white background. It was never about that tab. The light theme keeps `text-white` readable by remapping it to the theme's foreground colour, but that remapping only ever matched the plain utility, not the `hover:` variant Tailwind compiles to a different selector — so roughly 400 controls across the app that dim their label at rest and brighten it on hover were brightening it to literal white, whatever the theme. They now follow the theme like everything else. Dark themes are unaffected, their foreground colour already being white, and the handful of buttons that turn a solid accent colour on hover read better for it rather than worse.
- **Every camera stopped working on 1.2.5.4 (#3001, reported by @Jieper001, confirmed by @JmanB52D and @hikingthunder)** — live view, snapshots, timelapse frames and the camera diagnostic all failed at once on every RTSP model — X1, H2 and P2 — with the in-app diagnostic reporting `capture_exception` at 0 ms while network reachability passed at 1 ms. That 0 ms is the whole story: the failure happened before a socket was opened. The RTSPS proxy added in 1.2.5.4 finished by hanging its set of in-flight connection handlers on the server object as an attribute. `asyncio.start_server` returns an `asyncio.base_events.Server`, which has a `__dict__` and accepts that; under uvloop it returns a `uvloop.loop.Server`, a Cython cdef class with no `__dict__`, which raises `AttributeError` outright. Every launch path this repo ships — the Dockerfile, `install/install.sh`, `deploy/bambuddy.service`, the Windows service and the SpoolBuddy installer — pins `--loop asyncio`, added for #1896, so none of them selects uvloop and none of them could hit this. What broke is the installs running a unit file we did not write. The Proxmox VE Helper-Scripts LXC composes its own `ExecStart` with no loop pinned, and `requirements.txt` pins `uvicorn[standard]`, which installs uvloop on Linux, so uvicorn's default `--loop auto` selects it — that is the reporter's install and the two that confirmed it. Native installs created before the #1896 pin landed on 2026-07-05 are in the same position for a different reason: `install/update.sh` never rewrites the unit file, so a service written before that date has never been given the flag by any update since. Those installs are also still exposed to #1896 itself, where a truncated Virtual Printer FTP upload corrupts a `.gcode.3mf` silently; the camera outage is simply the visible half. Hence a fix in the code rather than another flag in a unit file: the proxy now works on either loop instead of depending on the launch command to steer around it. The handler set now lives in a module-level registry keyed weakly by server, which both loops accept; keying it weakly rather than by `id(server)` means a proxy abandoned without a close takes its entry with it, instead of leaking one forever and eventually handing a new server a dead one's handlers once CPython recycles the address. A1 and P1 use the chamber-image protocol and return before the proxy is built, so they were never affected, and external RTSPS cameras caught the error and fell back to a direct connection, so they kept working without the TLS workaround. The reason the test suite could not see any of this is that `conftest` builds its event loop from the default policy, so every async test in the repo runs on the selector loop — the one loop where the assignment was legal. The regression is now pinned twice: once by a test that drives the real function on a real uvloop loop, and once by a test that gives it a `__slots__` server, so the contract holds even where uvloop is not installed. The two external-camera teardowns were also switched to `close_tls_proxy`, which #2968 introduced and left them out of, so they no longer leave handlers running past the server that owned them.
- **Some archived 3MFs lost their G-code when re-imported into the File Manager (#2993, reported via the in-app form)** — they never lost it. The download serves the stored file byte for byte, and the G-code was still in the zip; what differed was who was asked. On the archive side the answer came from the file itself — the green GCODE badge reads the layer count and print time that were parsed out of the plate G-code — while the library decided from the filename alone, so a sliced 3MF stored as `Foo.3mf` rather than `Foo.gcode.3mf` carried the badge and still came back as a source-only project with no Print button. That splits on how the print reached the printer, not on anything about the file — a slicer's LAN send names it `.gcode.3mf`, while a per-plate export or a cloud-dispatched print arrives as plain `.3mf` — which is why it looked random. Both sides now ask the same question of the zip itself, and every route into the library (upload, ZIP import, MakerWorld, external-folder scan) classifies on content rather than on the name. Files already in your library are re-checked once on the next start. The backend was always willing to print these, so this was only ever the interface refusing to offer something that would have worked; a genuine model file is unaffected, and one that now shows **Print** correctly stops offering **Slice**.
+43 -3
View File
@@ -87,9 +87,10 @@ from backend.app.services.printer_media import (
remove_printer_files_zip,
start_printer_files_job,
)
from backend.app.services.slicer_filament_resolver import _ORCA_PROFILE_ID
from backend.app.services.slot_nozzle import resolve_slot_nozzle
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.filament_types import is_material_name, printer_filament_type
from backend.app.utils.fts_routing import slot_extruder
from backend.app.utils.http import build_content_disposition, download_error_response, safe_download_filename
from backend.app.utils.kprofile_lookup import build_slot_k_resolver
@@ -2849,10 +2850,49 @@ async def configure_ams_slot(
if not client:
raise HTTPException(status_code=400, detail="Printer not connected")
# Discard a tray_info_idx the printer cannot store (#3003).
#
# The field is 8 characters wide. A local preset id ("P" + 7 hex) is
# exactly 8, which is presumably why nobody noticed -- but a cloud
# *setting* id is 18, and the firmware keeps the first 8 and reports
# success. Measured on @marivo's A1 in the #3003 bundle:
#
# sent tray_info_idx=PFUS9ddc938fe3ab8f
# printer Assignment NOT confirmed: tray shows PFUS9DDC
#
# `PFUS9ddc` resolves to nothing anywhere, so the slot came out of the
# Configure modal as "Generic <material>" in the slicer -- strictly worse
# than the base filament it would have got from the fallback below, and it
# also breaks the calibration table, which is keyed by this field.
#
# Blanking it here is what hands the slot to the reuse / generic branch.
# The preset reference is not lost: it stays in setting_id, the field that
# does accept a PFUS. Same four rejected shapes, and the same reasoning, as
# `slicer_filament_resolver`'s closing guard -- which the assignment path
# has run since #1815 while Configure had none. The Orca profile UUID is on
# the list for the same reason as the rest: the modal no longer sends one,
# but this route is public API and 36 characters is the worst of the four
# against an 8-character field.
if tray_info_idx and (
tray_info_idx.startswith("PFUS")
or tray_info_idx.startswith("PFCN")
or _ORCA_PROFILE_ID.fullmatch(tray_info_idx)
or is_material_name(tray_info_idx)
):
logger.info(
"[configure_ams_slot] tray_info_idx %r is not storable as a filament id — "
"falling back to slot reuse / generic (kept as setting_id %r)",
tray_info_idx,
setting_id or tray_info_idx,
)
if not setting_id and (tray_info_idx.startswith("PFUS") or tray_info_idx.startswith("PFCN")):
setting_id = tray_info_idx
tray_info_idx = ""
# Resolve tray_info_idx for the MQTT command.
# Priority:
# 1. Use the provided tray_info_idx if set (including cloud-synced
# custom presets like PFUS* / P*).
# 1. Use the provided tray_info_idx if set, once the guard above has had
# its say (so: a GF* official or P* local id, never a PFUS/PFCN one).
# 2. Reuse the slot's existing tray_info_idx if it's a specific
# (non-generic) preset for the same material.
# 3. Fall back to a generic Bambu filament ID.
@@ -35,10 +35,12 @@ from __future__ import annotations
import json
import logging
import re
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.permissions import Permission
from backend.app.models.user import User
from backend.app.utils.filament_ids import (
GENERIC_FILAMENT_IDS,
@@ -49,6 +51,65 @@ from backend.app.utils.filament_types import is_material_name
logger = logging.getLogger(__name__)
# Orca Cloud profile ids are UUIDs, the one preset reference in this codebase
# with no letter prefix to key off. A spool stores the bare id (the spool form
# persists ``preset.setting_id`` verbatim), so shape is all there is to go on.
_ORCA_PROFILE_ID = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
async def _orca_filament_id(
db: AsyncSession,
current_user: User | None,
profile_id: str,
) -> tuple[str, str | None, str | None]:
"""Look up an Orca Cloud profile's own filament_id.
Returns ``(filament_id, name, filament_type)`` -- all empty/None when the
profile cannot be fetched or carries no id of its own, which leaves the
caller on its generic fallback.
Best-effort by construction: this runs inside spool assignment, not a user
request, so a missing pairing, a revoked token or a lapsed permission must
degrade to the fallback rather than fail the assignment. That is also why
``clear_on_auth_failure=False`` -- Orca reports every refresh rejection with
one composite reason, so a background caller cannot tell a real revocation
from a lost rotation race and must not wipe a working pairing on it. The
route path hits the same failure in front of a user and clears there.
"""
if current_user is not None and not current_user.has_permission(Permission.ORCA_CLOUD_AUTH.value):
logger.debug("Orca filament lookup skipped for %r: caller lacks orca_cloud:auth", profile_id)
return ("", None, None)
svc = None
try:
from backend.app.api.routes.orca_cloud import _build_authenticated_service
svc = await _build_authenticated_service(db, current_user, clear_on_auth_failure=False)
profile = await svc.get_profile(profile_id)
except Exception as e:
logger.debug("Orca filament lookup failed for %r: %s", profile_id, e)
return ("", None, None)
finally:
# A raise in `finally` escapes the `except` above, so guard it: closing
# an httpx client must never be what fails a spool assignment.
if svc is not None:
try:
await svc.close()
except Exception as e: # noqa: BLE001 - close() is best-effort
logger.debug("Orca client close failed after lookup of %r: %s", profile_id, e)
content = profile.get("content") if isinstance(profile, dict) else None
if not isinstance(content, dict):
return ("", None, None)
raw_fid = content.get("filament_id")
filament_id = raw_fid.strip() if isinstance(raw_fid, str) else ""
name = profile.get("name") if isinstance(profile, dict) else None
return (
filament_id,
name if isinstance(name, str) and name else None,
_preset_filament_type(content.get("filament_type")),
)
def _preset_filament_type(raw: object) -> str | None:
"""Read a slicer preset's ``filament_type`` field.
@@ -129,7 +190,26 @@ async def resolve_slicer_filament(
# All three need a cloud-detail lookup to extract the underlying
# filament_id; without it the raw cloud id ends up in tray_info_idx
# and the printer's calibration table can't resolve it.
if base_sf.startswith("GFS") or base_sf.startswith("PFUS") or base_sf.startswith("PFCN"):
# Source order is Orca Cloud, Bambu Cloud, local import, generic fallback.
# Orca goes first because its ids are the only ones identified by shape
# rather than prefix -- and because, before #3003, a UUID fell through every
# branch below into ``normalize_slicer_filament``, which passes anything it
# does not recognise straight through. A 36-character UUID then went into
# tray_info_idx, an 8-character field, and the slot ended up pointing at the
# first 8 characters of a UUID: the same failure the PFUS guard at the
# bottom of this function exists for.
if _ORCA_PROFILE_ID.fullmatch(base_sf):
tray_info_idx, orca_name, orca_type = await _orca_filament_id(db, current_user, base_sf)
if orca_type:
type_override = orca_type
if orca_name:
sub_brand_override = orca_name.split("@")[0].strip()
# setting_id is left empty here: the UUID is what the slicer cannot
# resolve, and unlike a PFUS there is no cloud id form it accepts
# instead. All three callers then derive one from the filament_id
# (`filament_id_to_setting_id`), which is what keeps the slot from
# going out half configured -- the same path a local import takes.
elif base_sf.startswith("GFS") or base_sf.startswith("PFUS") or base_sf.startswith("PFCN"):
setting_id = base_sf
try:
from backend.app.api.routes.cloud import build_authenticated_cloud
@@ -147,8 +227,21 @@ async def resolve_slicer_filament(
type_override = _preset_filament_type(
(cloud_setting if isinstance(cloud_setting, dict) else detail).get("filament_type")
)
if detail.get("filament_id"):
tray_info_idx = detail["filament_id"]
# A custom preset's OWN filament_id is the only thing that
# gets it into an AMS slot as itself: the printer stores
# that id, the slicer matches its presets against it, and
# the 8-character field fits it exactly ("P" + 7 hex).
# Bambu Cloud puts it in either of two places -- on the
# envelope, or inside the preset JSON under `setting` --
# the same spread `filament_type` above already handles.
# Reading only the envelope is how a custom preset fell
# through to the base_id branch below and reached the
# slicer as the Bambu profile it inherits from (#3003).
own_filament_id = detail.get("filament_id") or (
cloud_setting.get("filament_id") if isinstance(cloud_setting, dict) else None
)
if own_filament_id:
tray_info_idx = own_filament_id
cloud_name = detail.get("name", "")
if cloud_name:
sub_brand_override = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
@@ -245,10 +338,16 @@ async def resolve_slicer_filament(
# the original assign.
# 3. PFCN-prefix cloud shared / partner presets (e.g. Polymaker's
# "(Custom)" H2D variants, #1648) — same shape problem as PFUS.
# 4. Orca Cloud profile UUIDs, when the branch above could not reach the
# profile to trade one for its filament_id (#3003). Worst of the four
# at 36 characters against an 8-character field.
# 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 (
is_material_name(tray_info_idx) 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")
or _ORCA_PROFILE_ID.fullmatch(tray_info_idx)
):
tray_info_idx = ""
# Preserve setting_id when it's still a valid slicer reference
+88 -20
View File
@@ -1708,8 +1708,19 @@ class TestConfigureAMSSlotAPI:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_configure_pfus_sent_directly(self, async_client: AsyncClient, printer_factory):
"""PFUS* cloud-synced custom preset IDs are sent to the printer."""
async def test_configure_pfus_never_reaches_tray_info_idx(self, async_client: AsyncClient, printer_factory):
"""A PFUS* cloud setting_id is refused as tray_info_idx (#3003).
The printer's tray_info_idx field is 8 characters. An 18-character PFUS
is stored truncated and acknowledged as a success -- measured on the A1
in the #3003 bundle, which sent PFUS9ddc938fe3ab8f and read back
PFUS9DDC. That leaves the slot holding an id nothing resolves, so the
slicer shows "Generic" and the calibration table loses the slot too.
A generic for the material is strictly better, and the preset reference
survives in setting_id, which does accept a PFUS.
Reverses the contract #1053 pinned; see the route's own comment.
"""
printer = await printer_factory(name="H2D")
mock_client = MagicMock()
@@ -1738,12 +1749,20 @@ class TestConfigureAMSSlotAPI:
assert response.status_code == 200
call_kwargs = mock_client.ams_set_filament_setting.call_args
assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
# No tray to reuse -> generic for the material, never the raw PFUS.
assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
# The preset reference is not lost: it moves to the field that holds it.
assert call_kwargs.kwargs["setting_id"] == "PFUS9ac902733670a9"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_configure_pfus_takes_priority_over_slot(self, async_client: AsyncClient, printer_factory):
"""Provided PFUS* preset takes priority over slot's existing preset."""
async def test_configure_pfus_falls_back_to_slot_preset(self, async_client: AsyncClient, printer_factory):
"""With a PFUS refused, the slot's own resolvable preset is reused (#3003).
The slot already carries P4d64437 -- a local preset id, 8 characters, so
the printer can actually store it -- for the same material. That beats a
generic, and it is what the slot's calibration is keyed by.
"""
printer = await printer_factory(name="H2D")
mock_client = MagicMock()
@@ -1789,13 +1808,19 @@ class TestConfigureAMSSlotAPI:
assert response.status_code == 200
call_kwargs = mock_client.ams_set_filament_setting.call_args
# Provided preset wins over slot's existing one
assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
# Slot's own storable preset wins over both the PFUS and a generic.
assert call_kwargs.kwargs["tray_info_idx"] == "P4d64437"
assert call_kwargs.kwargs["setting_id"] == "PFUS9ac902733670a9"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_configure_pfus_used_regardless_of_slot_material(self, async_client: AsyncClient, printer_factory):
"""Provided PFUS* preset is used even when slot has a different material."""
async def test_configure_pfus_generic_when_slot_material_differs(self, async_client: AsyncClient, printer_factory):
"""A slot holding a different material is not reused (#3003).
Slot has generic PETG, the user is configuring PLA. Neither the refused
PFUS nor the mismatched slot can supply a filament id, so the generic
for the requested material does.
"""
printer = await printer_factory(name="H2D")
mock_client = MagicMock()
@@ -1834,8 +1859,8 @@ class TestConfigureAMSSlotAPI:
assert response.status_code == 200
call_kwargs = mock_client.ams_set_filament_setting.call_args
# Provided preset wins — slot's material is irrelevant
assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
assert call_kwargs.kwargs["setting_id"] == "PFUS9ac902733670a9"
@pytest.mark.asyncio
@pytest.mark.integration
@@ -1873,13 +1898,19 @@ class TestConfigureAMSSlotAPI:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_configure_pfus_preserves_setting_id_pair(self, async_client: AsyncClient, printer_factory):
"""Both tray_info_idx=PFUS* and setting_id=PFUS* are forwarded untouched.
async def test_configure_pfus_pair_splits_into_generic_and_setting_id(
self, async_client: AsyncClient, printer_factory
):
"""A PFUS sent in BOTH fields is kept only in setting_id (#3003).
Pins the end-to-end contract the frontend #1053 fix relies on: when the
user configures a slot with a custom cloud preset whose cloud detail
has filament_id=null, the frontend sends the setting_id in BOTH fields
and the backend must not collapse either to a generic GF* ID.
This is the shape the frontend produced when a custom cloud preset's
detail had filament_id=null, and what #1053 pinned. The A1 measurement
in #3003 showed where it ends up: the printer truncates tray_info_idx
to 8 characters, so the slot resolves to nothing and the slicer falls
back to "Generic" anyway -- the very outcome #1053 set out to avoid,
plus a broken calibration key. Sending the generic deliberately gets
the same slicer result honestly and keeps the slot calibratable, and
setting_id still carries the user's preset.
"""
printer = await printer_factory(name="H2D")
@@ -1910,10 +1941,47 @@ class TestConfigureAMSSlotAPI:
assert response.status_code == 200
call_kwargs = mock_client.ams_set_filament_setting.call_args
assert call_kwargs.kwargs["tray_info_idx"] == "PFUSa8fb76f9733e3c"
assert call_kwargs.kwargs["tray_info_idx"] == "GFB99"
assert call_kwargs.kwargs["setting_id"] == "PFUSa8fb76f9733e3c"
# Explicitly assert no generic-collapse happened for this HT slot.
assert call_kwargs.kwargs["tray_info_idx"] != "GFB99"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_configure_pfcn_refused_as_tray_info_idx(self, async_client: AsyncClient, printer_factory):
"""PFCN* shared / partner presets are refused the same way (#3003, #1648).
Same 18-character shape as a PFUS, same truncation. Polymaker's
"(Custom)" H2D variants are the ones that reach this in the wild.
"""
printer = await printer_factory(name="H2D")
mock_client = MagicMock()
mock_client.ams_set_filament_setting.return_value = True
mock_client.extrusion_cali_sel.return_value = True
mock_client.request_status_update.return_value = True
mock_status = MagicMock()
mock_status.raw_data = {"ams": {"ams": []}}
with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
mock_pm.get_client.return_value = mock_client
mock_pm.get_status.return_value = mock_status
response = await async_client.post(
f"/api/v1/printers/{printer.id}/slots/0/1/configure",
params={
"tray_info_idx": "PFCN2a91c7d0e4b118",
"tray_type": "PETG",
"tray_sub_brands": "Polymaker PETG (Custom)",
"tray_color": "0000FFFF",
"nozzle_temp_min": 220,
"nozzle_temp_max": 260,
},
)
assert response.status_code == 200
call_kwargs = mock_client.ams_set_filament_setting.call_args
assert call_kwargs.kwargs["tray_info_idx"] == "GFG99"
assert call_kwargs.kwargs["setting_id"] == "PFCN2a91c7d0e4b118"
class TestSkipObjectsAPI:
@@ -260,3 +260,226 @@ class TestThePresetsOwnType:
material="PETG",
)
assert type_override is None
class TestACustomPresetsOwnFilamentId:
"""Where the id that carries a custom preset into an AMS slot comes from.
The slot holds one filament reference and the printer truncates it to 8
characters, so a custom preset reaches the slicer as itself only when its
own filament_id ("P" + 7 hex) goes into ``tray_info_idx``. 92 trays across
eight models in the support archive do exactly that, so the mechanism
works -- what #3003 found is that we only ever read one of the two places
Bambu Cloud returns that id from.
"""
@pytest.mark.asyncio
async def test_filament_id_is_read_from_inside_the_preset_json(self):
"""The envelope has none, the preset JSON does -- and it wins over base_id.
Before #3003 this fell through to the base_id branch and the slot came
out as the Bambu profile the custom preset inherits from.
"""
db = MagicMock()
cloud = MagicMock()
cloud.is_authenticated = True
cloud.get_setting_detail = AsyncMock(
return_value={
"name": "SUNLU PLA Transparent @BBL A1",
"base_id": "GFSNLS03",
"setting": {"filament_id": "P4d64437", "filament_type": ["PLA"]},
}
)
cloud.close = AsyncMock()
with patch(
"backend.app.api.routes.cloud.build_authenticated_cloud",
AsyncMock(return_value=cloud),
):
idx, sid, brand, _type = await resolve_slicer_filament(
db=db,
current_user=None,
slicer_filament="PFUSfb87cd50b76616",
slicer_filament_name=None,
material="PLA",
)
assert idx == "P4d64437"
assert sid == "PFUSfb87cd50b76616"
assert brand == "SUNLU PLA Transparent"
@pytest.mark.asyncio
async def test_the_envelope_still_wins_when_it_has_one(self):
"""Unchanged behaviour for the presets that already resolved."""
db = MagicMock()
cloud = MagicMock()
cloud.is_authenticated = True
cloud.get_setting_detail = AsyncMock(
return_value={
"filament_id": "P285e239",
"name": "Jayo PETG HF @P1S",
"base_id": "GFSG02",
"setting": {"filament_id": "P999aaaa"},
}
)
cloud.close = AsyncMock()
with patch(
"backend.app.api.routes.cloud.build_authenticated_cloud",
AsyncMock(return_value=cloud),
):
idx, _sid, _brand, _type = await resolve_slicer_filament(
db=db,
current_user=None,
slicer_filament="PFUS992454068158eb",
slicer_filament_name=None,
material="PETG",
)
assert idx == "P285e239"
@pytest.mark.asyncio
async def test_base_id_is_still_the_fallback_when_neither_place_has_one(self):
"""A preset with no filament_id of its own genuinely is its base, and
the base id is storable, so it is the right answer -- just not one to
reach for while the preset's own id is sitting under ``setting``."""
db = MagicMock()
cloud = MagicMock()
cloud.is_authenticated = True
cloud.get_setting_detail = AsyncMock(
return_value={"name": "My PLA @BBL A1", "base_id": "GFSNLS03", "setting": {}}
)
cloud.close = AsyncMock()
with patch(
"backend.app.api.routes.cloud.build_authenticated_cloud",
AsyncMock(return_value=cloud),
):
idx, sid, _brand, _type = await resolve_slicer_filament(
db=db,
current_user=None,
slicer_filament="PFUSfb87cd50b76616",
slicer_filament_name=None,
material="PLA",
)
assert idx == "GFNLS03"
assert sid == "PFUSfb87cd50b76616"
class TestOrcaCloudIsTheFirstSource:
"""Source order is Orca Cloud, Bambu Cloud, local import, generic.
Orca was absent from the resolver entirely: a spool referencing an Orca
profile stores the bare UUID, which matched no branch and fell through
``normalize_slicer_filament`` -- a function that passes anything it does
not recognise straight through. The UUID reached tray_info_idx, a field
the printer truncates to 8 characters (#3003).
"""
ORCA_ID = "3f2a9c1e-4b7d-4a02-9f61-8c5e2d1a7b30"
@staticmethod
def _svc(profile):
svc = MagicMock()
svc.get_profile = AsyncMock(return_value=profile)
svc.close = AsyncMock()
return svc
@pytest.mark.asyncio
async def test_the_profiles_own_filament_id_is_used(self):
db = MagicMock()
svc = self._svc(
{
"id": self.ORCA_ID,
"name": "Overture Matte PLA @Orca",
"content": {"filament_id": "P56e1be0", "filament_type": ["PLA"]},
}
)
with patch(
"backend.app.api.routes.orca_cloud._build_authenticated_service",
AsyncMock(return_value=svc),
):
idx, sid, brand, type_override = await resolve_slicer_filament(
db=db,
current_user=None,
slicer_filament=self.ORCA_ID,
slicer_filament_name=None,
material="PLA",
)
assert idx == "P56e1be0"
# The UUID is foreign to the slicer in either field, so nothing carries it.
assert sid == ""
assert brand == "Overture Matte PLA"
assert type_override == "PLA"
svc.close.assert_awaited()
@pytest.mark.asyncio
async def test_a_profile_with_no_filament_id_leaves_the_caller_its_fallback(self):
db = MagicMock()
svc = self._svc({"id": self.ORCA_ID, "name": "My PLA", "content": {"filament_type": ["PLA"]}})
with patch(
"backend.app.api.routes.orca_cloud._build_authenticated_service",
AsyncMock(return_value=svc),
):
idx, sid, _brand, _type = await resolve_slicer_filament(
db=db,
current_user=None,
slicer_filament=self.ORCA_ID,
slicer_filament_name=None,
material="PLA",
)
assert idx == ""
assert sid == ""
@pytest.mark.asyncio
async def test_an_unreachable_orca_never_leaks_the_uuid(self):
"""No pairing, dead token, Orca down -- all the same answer. The UUID
must not reach tray_info_idx, which is what happened before the branch
existed at all."""
db = MagicMock()
with patch(
"backend.app.api.routes.orca_cloud._build_authenticated_service",
AsyncMock(side_effect=RuntimeError("Orca Cloud is not connected")),
):
idx, sid, _brand, _type = await resolve_slicer_filament(
db=db,
current_user=None,
slicer_filament=self.ORCA_ID,
slicer_filament_name=None,
material="PLA",
)
assert idx == ""
assert sid == ""
@pytest.mark.asyncio
async def test_a_caller_without_the_permission_skips_the_lookup(self):
db = MagicMock()
user = MagicMock()
user.has_permission = MagicMock(return_value=False)
with patch(
"backend.app.api.routes.orca_cloud._build_authenticated_service",
AsyncMock(side_effect=AssertionError("must not be called")),
):
idx, _sid, _brand, _type = await resolve_slicer_filament(
db=db,
current_user=user,
slicer_filament=self.ORCA_ID,
slicer_filament_name=None,
material="PLA",
)
assert idx == ""
@pytest.mark.asyncio
async def test_a_uuid_is_refused_as_tray_info_idx_by_the_closing_guard(self):
"""Belt and braces: a profile whose content names itself by UUID still
does not put one in the field."""
db = MagicMock()
svc = self._svc({"id": self.ORCA_ID, "name": "Odd", "content": {"filament_id": self.ORCA_ID}})
with patch(
"backend.app.api.routes.orca_cloud._build_authenticated_service",
AsyncMock(return_value=svc),
):
idx, sid, _brand, _type = await resolve_slicer_filament(
db=db,
current_user=None,
slicer_filament=self.ORCA_ID,
slicer_filament_name=None,
material="PLA",
)
assert idx == ""
assert sid == ""
@@ -24,6 +24,8 @@ vi.mock('../../api/client', () => ({
updateSettings: vi.fn().mockResolvedValue({}),
getLocalPresets: vi.fn(),
getBuiltinFilaments: vi.fn(),
orcaCloudListProfiles: vi.fn(),
orcaCloudGetProfile: vi.fn(),
searchColors: vi.fn(),
getColorCatalog: vi.fn(),
resetAmsSlot: vi.fn(),
@@ -85,6 +87,9 @@ describe('ConfigureAmsSlotModal', () => {
(api.configureAmsSlot as ReturnType<typeof vi.fn>).mockResolvedValue({ success: true });
(api.saveSlotPreset as ReturnType<typeof vi.fn>).mockResolvedValue({ success: true });
(api.getLocalPresets as ReturnType<typeof vi.fn>).mockResolvedValue({ filament: [] });
(api.orcaCloudListProfiles as ReturnType<typeof vi.fn>).mockResolvedValue({
filament: [], printer: [], process: [],
});
(api.getBuiltinFilaments as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(api.searchColors as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(api.getColorCatalog as ReturnType<typeof vi.fn>).mockResolvedValue([]);
@@ -206,7 +211,7 @@ describe('ConfigureAmsSlotModal', () => {
expect(colorInput).toHaveValue('Red');
});
it('sends PFUS setting_id as tray_info_idx when cloud detail has filament_id: null (#1053)', async () => {
it('sends no tray_info_idx when cloud detail has filament_id: null (#3003)', async () => {
// Cloud returns a user preset that inherits from a generic Bambu base and
// has no distinct filament_id of its own — this is how Bambu Cloud responds
// for custom presets built on top of "Generic ABS @BBL H2D" etc.
@@ -233,9 +238,118 @@ describe('ConfigureAmsSlotModal', () => {
});
const payload = (api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3];
// Before the fix, this collapsed to 'GFB99' (Generic ABS's filament_id),
// which made OrcaSlicer/BambuStudio Sync Filaments resolve to "Generic ABS".
expect(payload.tray_info_idx).toBe('PFUScd84f663d2c2ef');
// #1053 sent the PFUS here instead, to stop the slot collapsing to
// "Generic ABS" in the slicer. The A1 capture in #3003 showed that does not
// work: tray_info_idx is 8 characters on the printer, so an 18-character
// PFUS is stored truncated (PFUScd84 here) and acknowledged as a success.
// The slot then resolves to nothing — "Generic" anyway, plus a calibration
// table keyed by an id that does not exist. Sending nothing lets the
// backend pick a filament id the printer can actually hold; setting_id
// still carries the preset.
expect(payload.tray_info_idx).toBe('');
expect(payload.setting_id).toBe('PFUScd84f663d2c2ef');
});
it('uses an Orca Cloud profile\'s own filament_id instead of a generic (#3003)', async () => {
// Orca profile ids are UUIDs the printer cannot hold, so this branch used
// to skip the lookup entirely and send a generic — every Orca custom
// filament reached the slicer as "Generic PLA". The profile's slicer JSON
// carries a filament_id of exactly the storable shape; use it.
const ORCA_ID = '3f2a9c1e-4b7d-4a02-9f61-8c5e2d1a7b30';
(api.orcaCloudListProfiles as ReturnType<typeof vi.fn>).mockResolvedValue({
filament: [{ setting_id: ORCA_ID, name: 'Overture Matte PLA @Orca', type: 'filament', is_custom: true }],
printer: [],
process: [],
});
(api.orcaCloudGetProfile as ReturnType<typeof vi.fn>).mockResolvedValue({
setting_id: ORCA_ID,
name: 'Overture Matte PLA @Orca',
type: 'filament',
setting: { filament_id: 'P56e1be0', filament_type: ['PLA'] },
});
const slotInfo = { ...defaultProps.slotInfo, savedPresetId: ORCA_ID };
render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);
await waitFor(() => {
expect(screen.getByText('Overture Matte PLA @Orca')).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));
await waitFor(() => {
expect(api.configureAmsSlot).toHaveBeenCalled();
});
const payload = (api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3];
expect(payload.tray_info_idx).toBe('P56e1be0');
// The UUID is what the slicer cannot resolve; it goes in neither field.
expect(payload.setting_id).toBe('');
});
it('falls back to a generic when an Orca profile has no filament_id (#3003)', async () => {
const ORCA_ID = '3f2a9c1e-4b7d-4a02-9f61-8c5e2d1a7b30';
(api.orcaCloudListProfiles as ReturnType<typeof vi.fn>).mockResolvedValue({
filament: [{ setting_id: ORCA_ID, name: 'Homebrew PLA @Orca', type: 'filament', is_custom: true }],
printer: [],
process: [],
});
(api.orcaCloudGetProfile as ReturnType<typeof vi.fn>).mockResolvedValue({
setting_id: ORCA_ID,
name: 'Homebrew PLA @Orca',
type: 'filament',
setting: {},
});
const slotInfo = { ...defaultProps.slotInfo, savedPresetId: ORCA_ID };
render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);
await waitFor(() => {
expect(screen.getByText('Homebrew PLA @Orca')).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));
await waitFor(() => {
expect(api.configureAmsSlot).toHaveBeenCalled();
});
const payload = (api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3];
expect(payload.tray_info_idx).toBe('GFL99');
expect(payload.setting_id).toBe('');
});
it('uses the filament_id nested under setting when the envelope has none (#3003)', async () => {
// Bambu Cloud returns a custom preset's own filament_id on the envelope for
// some presets and inside the preset JSON for others. Only the envelope was
// read, so presets of the second shape reached the slot as their inherited
// base — the "Bambu profile instead of my profile" half of #3003.
(api.getCloudSettingDetail as ReturnType<typeof vi.fn>).mockResolvedValue({
filament_id: null,
base_id: 'GFSB99_07',
name: '# Overture Matte PLA @BBL H2D',
setting: { filament_id: 'P4d64437' },
});
const slotInfo = {
...defaultProps.slotInfo,
savedPresetId: 'PFUScd84f663d2c2ef',
};
render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);
await waitFor(() => {
expect(screen.getByText('# Overture Matte PLA @BBL H2D')).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));
await waitFor(() => {
expect(api.configureAmsSlot).toHaveBeenCalled();
});
const payload = (api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3];
// 8 characters, so the printer stores it whole and the slicer matches it.
expect(payload.tray_info_idx).toBe('P4d64437');
expect(payload.setting_id).toBe('PFUScd84f663d2c2ef');
});
@@ -293,9 +407,11 @@ describe('ConfigureAmsSlotModal', () => {
expect(api.getCloudSettingDetail).not.toHaveBeenCalled();
});
it('keeps default PFUS tray_info_idx when cloud detail fetch fails', async () => {
// Network/5xx from /cloud/settings/{id} must not abort the configure flow
// nor leave tray_info_idx empty — we fall back to the setting_id default.
it('sends no tray_info_idx when the cloud detail fetch fails (#3003)', async () => {
// Network/5xx from /cloud/settings/{id} must not abort the configure flow.
// It leaves no filament_id to send, and the PFUS default is not a usable
// stand-in (see the filament_id: null case above), so the field goes out
// empty and the backend resolves it.
(api.getCloudSettingDetail as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('cloud unreachable')
);
@@ -317,7 +433,7 @@ describe('ConfigureAmsSlotModal', () => {
});
const payload = (api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3];
expect(payload.tray_info_idx).toBe('PFUScd84f663d2c2ef');
expect(payload.tray_info_idx).toBe('');
expect(payload.setting_id).toBe('PFUScd84f663d2c2ef');
});
@@ -469,16 +469,40 @@ export function ConfigureAmsSlotModal({
|| '';
settingId = '';
} else if (isOrca) {
// Orca Cloud presets have a UUID setting_id that Bambu printers can't
// resolve; treat them like local imports — derive a generic tray_info
// _idx from the parsed material, leave settingId empty so the slicer
// doesn't get a foreign cloud ID it can't look up.
const material = (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : parsed.material || '').toUpperCase();
trayInfoIdx = GENERIC_IDS[material]
|| GENERIC_IDS[material.replace(/[-\s]?CF$/, '')]
|| GENERIC_IDS[material.replace(/\+$/, '')]
|| GENERIC_IDS[material.split(/[-\s]/)[0]]
|| '';
// An Orca Cloud profile's setting_id is a UUID the printer cannot hold,
// so this used to skip straight to a generic — which meant every Orca
// custom filament reached the slicer as "Generic <material>", without
// ever asking whether the profile had a usable id (#3003).
//
// It usually does. The profile's slicer JSON carries its own
// filament_id, the same 8-character "P…" the printer stores for a
// Bambu custom preset, and OrcaProfileDetail deliberately exposes that
// JSON under `setting` in the shape SlicerSettingDetail uses. So the
// lookup is the same one the Bambu Cloud branch does below.
//
// settingId stays empty either way: it is the UUID that is foreign to
// the slicer, not the filament id.
let orcaFilamentId = '';
try {
const orcaDetail = await api.orcaCloudGetProfile(orcaSettingId);
const fromProfile = orcaDetail.setting?.filament_id;
if (typeof fromProfile === 'string' && fromProfile) {
orcaFilamentId = fromProfile;
}
} catch (e) {
console.warn('Failed to fetch Orca Cloud profile for filament_id:', e);
}
trayInfoIdx = orcaFilamentId;
if (!trayInfoIdx) {
// No id of its own — fall back to the generic for the material, the
// same last resort every other source ends at.
const material = (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : parsed.material || '').toUpperCase();
trayInfoIdx = GENERIC_IDS[material]
|| GENERIC_IDS[material.replace(/[-\s]?CF$/, '')]
|| GENERIC_IDS[material.replace(/\+$/, '')]
|| GENERIC_IDS[material.split(/[-\s]/)[0]]
|| '';
}
settingId = '';
} else if (isBuiltin) {
// Built-in presets use the filament_id directly as tray_info_idx
@@ -495,13 +519,38 @@ export function ConfigureAmsSlotModal({
if (!selectedPresetId.startsWith('GFS')) {
try {
const detail = await api.getCloudSettingDetail(selectedPresetId);
if (detail.filament_id) {
trayInfoIdx = detail.filament_id;
// The preset's own filament_id is what puts it in the slot as
// itself — the printer stores that id and the slicer matches its
// presets against it. Bambu Cloud returns it on the envelope for
// some presets and inside the preset JSON under `setting` for
// others; looking only at the envelope is how a custom preset
// silently became its inherited base profile (#3003).
const nested = detail.setting?.filament_id;
const ownFilamentId = detail.filament_id || (typeof nested === 'string' ? nested : '');
if (ownFilamentId) {
trayInfoIdx = ownFilamentId;
}
} catch (e) {
console.warn('Failed to fetch preset detail for filament_id:', e);
}
}
// Last resort: neither place had one, so trayInfoIdx is still the
// cloud setting_id — and sending one is worse than sending nothing
// (#3003). tray_info_idx is 8 characters on the printer; an
// 18-character PFUS is stored truncated and acknowledged as a success,
// so the slot ends up holding an id that resolves nowhere — the slicer
// shows "Generic", and the calibration table, keyed by this field,
// loses the slot too. Blank means the backend picks the slot's
// existing filament id or a generic for the material; settingId still
// carries the preset.
if (/^(PFUS|PFSP|PFCN)/.test(trayInfoIdx)) {
console.warn(
`Cloud preset ${selectedPresetId} carries no filament_id in either place; ` +
'sending no tray_info_idx so the printer keeps a resolvable one.'
);
trayInfoIdx = '';
}
}
// Default temp range — use local preset core fields if available
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-DQoG4yeu.js"></script>
<script type="module" crossorigin src="/assets/index-BMnY4ID9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ChscM3lF.css">
</head>
<body>