From 922f781a4be03928249e2ea94aa7fbe5a062154f Mon Sep 17 00:00:00 2001 From: maziggy Date: Fri, 9 Oct 2026 14:28:45 +0200 Subject: [PATCH] Fill in missing generic filament IDs and fix support-filament setting IDs (#3273) --- CHANGELOG.md | 2 + backend/app/api/routes/inventory.py | 4 +- backend/app/api/routes/printers.py | 69 ++++------------ backend/app/utils/filament_ids.py | 49 +++++++++-- .../integration/test_inventory_assign.py | 33 ++++++++ .../test_spoolman_slot_assignment_mqtt.py | 38 +++++++++ .../tests/unit/test_generic_filament_ids.py | 82 +++++++++++++++++++ .../utils/test_printer_filament_type_2902.py | 2 +- 8 files changed, 215 insertions(+), 64 deletions(-) create mode 100644 backend/tests/unit/test_generic_filament_ids.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4426945c9..0943af3d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -161,6 +161,8 @@ All notable changes to Bambuddy will be documented in this file. - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows. ### Fixed +- **A PP spool without a slicer preset was sent to the AMS slot with no filament ID (#3273, reported by @sgiffhorn)** — When a spool has no preset of its own, Bambuddy fills in Bambu's generic preset for its material. The table used for spool assignment had no entry for PP, PE, PCTG, PLA Silk or PLA High Speed, so those slots got an empty filament ID, while **Configure AMS Slot** used a separate, longer table. The backend now has one table with all five, matching the one the frontend already used. A slot already set to Generic PLA Silk or PLA High Speed still keeps it when a PLA spool without a preset is assigned, as before. + - **PVA, HIPS and Bambu support filaments got the wrong setting ID:** their filament IDs start with `GFS` (Generic PVA is `GFS99`), which Bambuddy took for a setting ID. It sent `GFS99` instead of `GFSS99` and read it back as `GF99`. - **Every start filled the PostgreSQL log with about 320 errors** — On PostgreSQL, the startup migrations re-ran every column, index and constraint they had already added, and the database logged each of those as an `ERROR` with its statement, about 640 lines per start, before Bambuddy quietly skipped it. Nothing was wrong, but the noise buried real errors and alarmed anyone reading the log, on the appliance as much as on Docker with PostgreSQL. On PostgreSQL these statements now ask the database to skip what is already there (`IF NOT EXISTS`, or a check of the catalog for constraints and renames), so a normal start logs no errors. Columns, renames and constraints that are actually missing are still added, and SQLite is unchanged. PostgreSQL 13 or newer is required, and the wiki now says so: Bambuddy has never started on PostgreSQL 12 or older, whose startup migrations fail on a column type those versions reject. - **A large timelapse could not be attached when it took more than 5 minutes to download (#3272, reported by @dovmesiz)** — Scanning for a timelapse, picking one by hand and the automatic attach after a print all gave the download a flat 300 seconds. A 75 MB video that took 358 seconds over a healthy P1S link failed with "Failed to download timelapse", and each retry started the file again from scratch. The time allowed now follows the file's size, so a slow transfer completes; a link that stops sending still fails after the FTP timeout as before. - **A printing queue job showed the slot AMS Filament Backup had switched away from as empty** — When a spool ran out mid-print and the printer carried on from the backup spool, the job's card in the queue still named the original slot, now marked **Empty**, as if the print had a problem. The card now shows the backup spool the printer is using. It follows the printer's own record of the trays the print has drawn from, including a backup that ran out in turn. A switch between two slots the job uses is a normal colour change and is not taken for a backup. Queued jobs still warn about an empty slot as before. diff --git a/backend/app/api/routes/inventory.py b/backend/app/api/routes/inventory.py index cfa375acf..e467e4b4d 100644 --- a/backend/app/api/routes/inventory.py +++ b/backend/app/api/routes/inventory.py @@ -87,6 +87,7 @@ from backend.app.services.supplier_links import apply_supplier_inheritance, appl from backend.app.services.tag_conflict import tag_already_linked from backend.app.utils.filament_ids import ( GENERIC_FILAMENT_IDS, + GENERIC_IDS_REPLACED_ON_REUSE, filament_id_to_setting_id, normalize_slicer_filament, ) @@ -96,7 +97,6 @@ from backend.app.utils.tag_normalization import normalize_tag_uid, normalize_tra logger = logging.getLogger(__name__) -_GENERIC_ID_VALUES = set(GENERIC_FILAMENT_IDS.values()) router = APIRouter(prefix="/inventory", tags=["inventory"]) @@ -165,7 +165,7 @@ async def apply_spool_to_slot_via_mqtt( tray_sub_brands = " ".join(p for p in (spool.brand, spool.material, spool.subtype) if p) or spool.material tray_color = spool.rgba or "FFFFFFFF" - _generic_id_values = _GENERIC_ID_VALUES + _generic_id_values = GENERIC_IDS_REPLACED_ON_REUSE # Which nozzle this slot feeds, and how wide it is. One resolution shared # with every other path that configures a slot (see services.slot_nozzle), diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index 59b3c78da..855212f32 100644 --- a/backend/app/api/routes/printers.py +++ b/backend/app/api/routes/printers.py @@ -93,7 +93,11 @@ from backend.app.services.printer_media import ( from backend.app.services.slicer_filament_resolver import _ORCA_PROFILE_ID, lookup_orca_filament_id from backend.app.services.slot_nozzle import resolve_slot_nozzle from backend.app.utils.ams_humidity import ams_humidity_percent -from backend.app.utils.filament_ids import filament_id_to_setting_id +from backend.app.utils.filament_ids import ( + GENERIC_FILAMENT_IDS, + GENERIC_IDS_REPLACED_ON_REUSE, + filament_id_to_setting_id, +) 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 @@ -2818,42 +2822,15 @@ async def get_slot_spool_defaults( } -# Generic Bambu filament ids by material, as the Configure dialog has always -# picked them for a preset without an id of its own (#3216 moved the Orca case -# here). Wider than configure_ams_slot's own table: Silk, High Speed, PCTG, PE -# and PP have generics of their own. -_ORCA_GENERIC_IDS = { - "PLA": "GFL99", - "PLA-CF": "GFL98", - "PLA SILK": "GFL96", - "PLA HIGH SPEED": "GFL95", - "PETG": "GFG99", - "PETG HF": "GFG96", - "PETG-CF": "GFG98", - "PCTG": "GFG97", - "ABS": "GFB99", - "ASA": "GFB98", - "PC": "GFC99", - "PA": "GFN99", - "PA-CF": "GFN98", - "NYLON": "GFN99", - "TPU": "GFU99", - "PVA": "GFS99", - "HIPS": "GFS98", - "PE": "GFP99", - "PP": "GFP97", -} - - def _orca_generic_filament_id(material: str) -> str: """The generic for a material, tried as given, without a CF suffix, without a trailing "+", then by its first word -- the dialog's order.""" material = (material or "").upper().strip() return ( - _ORCA_GENERIC_IDS.get(material) - or _ORCA_GENERIC_IDS.get(re.sub(r"[-\s]?CF$", "", material)) - or _ORCA_GENERIC_IDS.get(re.sub(r"\+$", "", material)) - or _ORCA_GENERIC_IDS.get(re.split(r"[-\s]", material)[0]) + GENERIC_FILAMENT_IDS.get(material) + or GENERIC_FILAMENT_IDS.get(re.sub(r"[-\s]?CF$", "", material)) + or GENERIC_FILAMENT_IDS.get(re.sub(r"\+$", "", material)) + or GENERIC_FILAMENT_IDS.get(re.split(r"[-\s]", material)[0]) or "" ) @@ -2992,8 +2969,7 @@ async def configure_ams_slot( else: orca_fallback_reason = reason or "no_filament_id" # The generic the Configure dialog picked for an Orca profile before - # the lookup moved here -- its table, not the shorter one below, so - # PLA Silk, PCTG, PP and PE keep their own generics. + # the lookup moved here, with the dialog's own lookup order. tray_info_idx = _orca_generic_filament_id(requested_tray_type) logger.info( "[configure_ams_slot] Orca profile %r → tray_info_idx=%r (%s)", @@ -3009,23 +2985,6 @@ async def configure_ams_slot( # 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. - _GENERIC_FILAMENT_IDS = { - "PLA": "GFL99", - "PETG": "GFG99", - "ABS": "GFB99", - "ASA": "GFB98", - "PC": "GFC99", - "PA": "GFN99", - "NYLON": "GFN99", - "TPU": "GFU99", - "PVA": "GFS99", - "HIPS": "GFS98", - "PLA-CF": "GFL98", - "PETG-CF": "GFG98", - "PA-CF": "GFN98", - "PETG HF": "GFG96", - } - _GENERIC_ID_VALUES = set(_GENERIC_FILAMENT_IDS.values()) effective_tray_info_idx = tray_info_idx if not tray_info_idx: @@ -3064,7 +3023,7 @@ async def configure_ams_slot( # preset in front of the slicer. It gets the generic for its material. not orca_profile_id and current_tray_info_idx - and current_tray_info_idx not in _GENERIC_ID_VALUES + and current_tray_info_idx not in GENERIC_IDS_REPLACED_ON_REUSE and current_tray_type and current_tray_type.upper() == tray_type.upper() ): @@ -3081,9 +3040,9 @@ async def configure_ams_slot( # 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()) + 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: diff --git a/backend/app/utils/filament_ids.py b/backend/app/utils/filament_ids.py index b9cac7556..97f510266 100644 --- a/backend/app/utils/filament_ids.py +++ b/backend/app/utils/filament_ids.py @@ -24,6 +24,9 @@ MATERIAL_TEMPS: dict[str, tuple[int, int]] = { "PA-CF": (270, 300), } +# Bambu's generic preset per material, for a slot whose spool has no preset of +# its own. Every assignment path and the Configure dialog read this one table; +# a material missing here goes to the printer with an empty tray_info_idx (#3273). GENERIC_FILAMENT_IDS: dict[str, str] = { "PLA": "GFL99", "PETG": "GFG99", @@ -39,13 +42,42 @@ GENERIC_FILAMENT_IDS: dict[str, str] = { "PETG-CF": "GFG98", "PA-CF": "GFN98", "PETG HF": "GFG96", + "PLA SILK": "GFL96", + "PLA HIGH SPEED": "GFL95", + "PCTG": "GFG97", + "PE": "GFP99", + "PP": "GFP97", } +# The generics the slot-reuse check replaces instead of carrying forward: +# a slot holding one of these gets the generic for the new spool's own +# material. Deliberately the set from before the table above grew (#3273), so +# a slot set to Generic PLA Silk or PLA High Speed still keeps it for a +# preset-less PLA spool, as it always has. +GENERIC_IDS_REPLACED_ON_REUSE: frozenset[str] = frozenset( + { + "GFL99", + "GFL98", + "GFG99", + "GFG98", + "GFG96", + "GFB99", + "GFB98", + "GFC99", + "GFN99", + "GFN98", + "GFU99", + "GFS99", + "GFS98", + } +) + def filament_id_to_setting_id(filament_id: str) -> str: """Convert filament_id → setting_id (e.g. "GFL05" → "GFSL05"). - - Already a setting_id ("GFS…") → returned unchanged. + - Already a setting_id ("GFSL05", "GFSS99") → returned unchanged; a + support filament_id ("GFS99") still converts. - User presets ("P…") → returned unchanged. - Empty / unknown → returned unchanged. """ @@ -58,8 +90,11 @@ def filament_id_to_setting_id(filament_id: str) -> str: # Official Bambu presets: GFx## -> GFSx## if filament_id.startswith("GF") and len(filament_id) >= 4: - # Already a setting_id (has S after GF) - if filament_id[2] == "S": + # Already a setting_id: "GFS" then the family letter ("GFSL05"). + # Support filaments are family S, so their filament_id starts with + # "GFS" too -- but a digit follows it ("GFS99" is Generic PVA, whose + # setting_id is "GFSS99"). + if filament_id[2] == "S" and filament_id[3].isalpha(): return filament_id return f"GFS{filament_id[2:]}" @@ -69,7 +104,8 @@ def filament_id_to_setting_id(filament_id: str) -> str: def setting_id_to_filament_id(setting_id: str) -> str: """Convert setting_id → filament_id (e.g. "GFSL05" → "GFL05"). - - Already a filament_id ("GF" without "S") → returned unchanged. + - Already a filament_id ("GFL05", or a support one like "GFS99") → + returned unchanged. - User presets ("P…") → returned unchanged. - Empty / unknown → returned unchanged. """ @@ -80,8 +116,9 @@ def setting_id_to_filament_id(setting_id: str) -> str: if setting_id.startswith("P"): return setting_id - # Setting_id format: GFSx## -> GFx## (remove the "S") - if setting_id.startswith("GFS") and len(setting_id) >= 5: + # Setting_id format: GFSx## -> GFx## (remove the "S"). A digit after + # "GFS" means a support filament_id, not a setting_id (see above). + if setting_id.startswith("GFS") and len(setting_id) >= 5 and setting_id[3].isalpha(): return f"GF{setting_id[3:]}" return setting_id diff --git a/backend/tests/integration/test_inventory_assign.py b/backend/tests/integration/test_inventory_assign.py index 66835fbe0..0472248ad 100644 --- a/backend/tests/integration/test_inventory_assign.py +++ b/backend/tests/integration/test_inventory_assign.py @@ -334,6 +334,39 @@ class TestAssignSpoolTrayInfoIdx: # Slot's specific preset is reused when spool has no own preset assert call_kwargs.kwargs["tray_info_idx"] == "GFA05" + @pytest.mark.asyncio + @pytest.mark.integration + @pytest.mark.parametrize("slot_generic", ["GFL96", "GFL95"]) + async def test_no_preset_keeps_a_silk_or_high_speed_generic_on_the_slot( + self, async_client: AsyncClient, printer_factory, spool_factory, slot_generic + ): + """PLA Silk and PLA High Speed joined the generic table (#3273), but the + reuse check kept its old set: a slot set to one of them still keeps it + for a preset-less PLA spool rather than dropping to Generic PLA.""" + printer = await printer_factory(name="X1C") + spool = await spool_factory(slicer_filament=None, material="PLA") + + mock_client = MagicMock() + mock_client.ams_set_filament_setting.return_value = True + mock_client.extrusion_cali_sel.return_value = True + + status = _make_mock_status( + ams_data=[{"id": 0, "tray": [{"id": 0, "tray_info_idx": slot_generic, "tray_type": "PLA"}]}] + ) + + with patch("backend.app.services.printer_manager.printer_manager") as mock_pm: + mock_pm.get_client.return_value = mock_client + mock_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 + call_kwargs = mock_client.ams_set_filament_setting.call_args + assert call_kwargs.kwargs["tray_info_idx"] == slot_generic + class TestAssignSpoolPresetMapping: """Tests that assign_spool saves the slot preset mapping for correct UI display.""" diff --git a/backend/tests/integration/test_spoolman_slot_assignment_mqtt.py b/backend/tests/integration/test_spoolman_slot_assignment_mqtt.py index adff200c4..1c65b29b5 100644 --- a/backend/tests/integration/test_spoolman_slot_assignment_mqtt.py +++ b/backend/tests/integration/test_spoolman_slot_assignment_mqtt.py @@ -1038,3 +1038,41 @@ class TestSlicerFilamentResolutionParity: "filament_id_to_setting_id-derived so the slot detail modal " "doesn't render with empty fields." ) + + @pytest.mark.asyncio + @pytest.mark.integration + @pytest.mark.parametrize( + "material,expected", + [("PP", "GFP97"), ("PE", "GFP99"), ("PCTG", "GFG97"), ("PLA Silk", "GFL96")], + ) + async def test_generic_fallback_covers_every_material_with_a_bambu_generic( + self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, material, expected + ): + """A PP spool without a preset went out with tray_info_idx="" (#3273): + the shared table lacked the generics the Configure dialog already had.""" + spool = {**SAMPLE_SPOOL, "extra": {}, "filament": {**SAMPLE_SPOOL["filament"], "material": material}} + mock_spoolman_client.get_spool = AsyncMock(return_value=spool) + + mqtt_mock = MagicMock() + mqtt_mock.ams_set_filament_setting = MagicMock() + mqtt_mock.extrusion_cali_sel = MagicMock() + mqtt_mock.printer_state = None + + with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock: + pm_mock.get_client = MagicMock(return_value=mqtt_mock) + pm_mock.get_status = MagicMock(return_value=None) + + response = await async_client.post( + "/api/v1/spoolman/inventory/slot-assignments", + json={ + "spoolman_spool_id": 10, + "printer_id": test_printer.id, + "ams_id": 0, + "tray_id": 0, + }, + ) + + assert response.status_code == 200 + call_kwargs = mqtt_mock.ams_set_filament_setting.call_args[1] + assert call_kwargs["tray_info_idx"] == expected + assert call_kwargs["setting_id"] == "GFS" + expected[2:] diff --git a/backend/tests/unit/test_generic_filament_ids.py b/backend/tests/unit/test_generic_filament_ids.py new file mode 100644 index 000000000..9a677fba6 --- /dev/null +++ b/backend/tests/unit/test_generic_filament_ids.py @@ -0,0 +1,82 @@ +"""One table of generic Bambu filament ids, complete for every generic Bambu has. + +A PP spool without a preset was sent to the printer with tray_info_idx="" +because the shared table had no PP row, while the Configure dialog's own copy +did (#3273). The copies are gone; these tests keep the table complete. +""" + +from __future__ import annotations + +import pytest + +from backend.app.api.routes import printers +from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES +from backend.app.utils.filament_ids import ( + GENERIC_FILAMENT_IDS, + GENERIC_IDS_REPLACED_ON_REUSE, + filament_id_to_setting_id, + normalize_slicer_filament, + setting_id_to_filament_id, +) +from backend.app.utils.filament_types import is_material_name + + +@pytest.mark.parametrize( + "material,expected", + [ + ("PP", "GFP97"), + ("PE", "GFP99"), + ("PCTG", "GFG97"), + ("PLA SILK", "GFL96"), + ("PLA HIGH SPEED", "GFL95"), + ], +) +def test_the_materials_that_were_missing_have_their_generic(material, expected): + assert GENERIC_FILAMENT_IDS[material] == expected + + +def test_every_id_is_a_bambu_generic_preset(): + for material, filament_id in GENERIC_FILAMENT_IDS.items(): + name = _BUILTIN_FILAMENT_NAMES.get(filament_id, "") + assert name.startswith("Generic "), f"{material} -> {filament_id} is not a generic preset ({name!r})" + assert filament_id_to_setting_id(filament_id) == "GFS" + filament_id[2:] + + +def test_the_reuse_check_replaces_exactly_the_generics_it_always_has(): + """The table grew; what slot reuse replaces must not have grown with it.""" + added = {"GFL96", "GFL95", "GFG97", "GFP99", "GFP97"} + assert set(GENERIC_FILAMENT_IDS.values()) - added == GENERIC_IDS_REPLACED_ON_REUSE + assert not hasattr(printers, "_GENERIC_ID_VALUES") + + +def test_the_configure_route_keeps_no_copy_of_its_own(): + assert not hasattr(printers, "_ORCA_GENERIC_IDS") + assert printers._orca_generic_filament_id("PP") == "GFP97" + assert printers._orca_generic_filament_id("PLA Silk") == "GFL96" + + +@pytest.mark.parametrize("material", ["PP", "PE", "PCTG"]) +def test_a_material_with_a_generic_is_a_material_name_not_a_preset_id(material): + """So the resolver discards it as a preset and the generic rescues the slot.""" + assert is_material_name(material) + + +@pytest.mark.parametrize( + "filament_id,setting_id", + [ + ("GFS99", "GFSS99"), # Generic PVA + ("GFS98", "GFSS98"), # Generic HIPS + ("GFS00", "GFSS00"), # Bambu Support W + ("GFL05", "GFSL05"), + ], +) +def test_support_filaments_convert_both_ways(filament_id, setting_id): + """Support filaments are family S, so their filament_id starts with "GFS" + too. Read as a setting_id, Generic PVA went out as setting_id "GFS99" and + came back as filament_id "GF99"; Bambu's own profile says GFSS99.""" + assert filament_id_to_setting_id(filament_id) == setting_id + assert filament_id_to_setting_id(setting_id) == setting_id + assert setting_id_to_filament_id(setting_id) == filament_id + assert setting_id_to_filament_id(filament_id) == filament_id + assert normalize_slicer_filament(filament_id) == (filament_id, setting_id) + assert normalize_slicer_filament(setting_id + "_07") == (filament_id, setting_id) diff --git a/backend/tests/unit/utils/test_printer_filament_type_2902.py b/backend/tests/unit/utils/test_printer_filament_type_2902.py index 9e0ff721e..2a82d0013 100644 --- a/backend/tests/unit/utils/test_printer_filament_type_2902.py +++ b/backend/tests/unit/utils/test_printer_filament_type_2902.py @@ -251,7 +251,7 @@ class TestIsMaterialName: 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", "PPS-GF", "PEEK", "PA6-CF", "CPE HG100", "XT", "", None]) + @pytest.mark.parametrize("unknown", ["PPS-CF", "PPS-GF", "PEEK", "PA6-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