mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
fix(ams): keep PFUS preset id when cloud filament_id is null (#1053)
Bambu Cloud returns filament_id=null for user presets that only override
fields of a generic base (e.g. "Sting3D ABS" inheriting from
"Generic ABS @BBL H2D"). ConfigureAmsSlotModal fell back to
convertToTrayInfoIdx(base_id), which strips "S" and the version suffix
from "GFSB99_07" to "GFB99" — Generic ABS's filament_id. The printer
accepted and echoed back GFB99, so OrcaSlicer / BambuStudio Sync
Filaments resolved the slot to "Generic ABS" and the custom preset
never appeared on the printer LCD.
The preceding default already set tray_info_idx to the PFUS*/PFSP*
setting_id unchanged, and the rest of the stack round-trips that
format (configure_ams_slot, inventory Assign Spool, and print
scheduler slot-matching on P* short-form IDs). The base_id branch
overwrote the correct default.
Remove the base_id fallback. When cloud detail returns a distinct
filament_id we still prefer it; otherwise the setting_id default
stands. BambuStudio Sync now resolves the custom preset cleanly.
OrcaSlicer falls back to the inherited generic because OrcaSlicer
user-preset JSONs don't carry a filament_id field — that is an
OrcaSlicer limitation and behaviour is strictly not worse than before.
Regression tests (frontend):
- filament_id=null keeps PFUS* as tray_info_idx
- concrete filament_id wins over the default
- GFS* path skips the cloud-detail fetch entirely
- fetch failure degrades gracefully to the PFUS* default
Regression tests (backend):
- test_configure_pfus_preserves_setting_id_pair: HT slot endpoint
forwards both tray_info_idx=PFUS… and setting_id=PFUS… untouched
Thanks to @mrnoisytiger for the browser-console / network / backend-log
data that isolated the fallback path and the OrcaSlicer preset JSON
that showed the missing filament_id field.
This commit is contained in:
@@ -8,6 +8,7 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Printer Card Shows Plate Name on Multi-Plate Prints** ([#881](https://github.com/maziggy/bambuddy/issues/881)) — When two printers were running different plates of the same multi-plate 3MF, the Printers page cards displayed the same file name on both and gave no visual way to tell them apart. The Queue view already showed the plate name by querying the archive's plate list; the Printers page didn't have that linkage. The `GET /printers/{id}/status` endpoint now returns `current_archive_id` (resolved by matching the MQTT `subtask_id` against `PrintArchive.subtask_id`, the same bridge introduced in #972 for restart-resume) and `current_plate_id` (parsed from the MQTT `gcode_file` path by a new shared `parse_plate_id` helper that's also used by the WebSocket push path, so plate transitions within a running print reflect immediately instead of waiting 30 s for the next REST poll). The card fetches plate metadata via the same `api.getArchivePlates()` call the Queue page uses — shared React Query cache keeps it cheap across polls — and renders the actual plate name (or a "Plate N" fallback) only when the source 3MF is multi-plate, so single-plate prints stay noise-free. Falls back to the previous `plate_(\d+).gcode` regex when there's no archive linkage (e.g. prints started directly from the printer LCD). Regression tests cover the plate-id extraction across Bambu Studio path shapes and the label-override precedence in `formatPrintName`. Thanks to @stringham for the follow-up and screenshot.
|
||||
|
||||
### Fixed
|
||||
- **AMS Slot Configure: Custom Cloud Preset Resolves to "Generic" in Slicer & Printer LCD** ([#1053](https://github.com/maziggy/bambuddy/issues/1053) follow-up) — After configuring any AMS slot (HT or regular) with a user custom Bambu Cloud preset built on top of a Bambu base profile (e.g. "Sting3D ABS" inheriting from "Generic ABS @BBL H2D"), OrcaSlicer's *Sync Filaments* continued to resolve the slot to "Generic ABS" and the custom preset never appeared on the printer's own LCD — independent of the earlier UI fix (commit `87a5aa36`) which only corrected Bambuddy's own modal. Root cause: when Bambu Cloud's `GET /cloud/settings/{setting_id}` returns a user preset with `filament_id: null` and `base_id: "GFSB99_07"` (cloud doesn't mint a distinct filament_id for presets that only override fields of a generic base), `ConfigureAmsSlotModal.tsx:382-384` fell back to `convertToTrayInfoIdx(base_id)` which strips the version suffix and the `S` prefix → `"GFB99"` — Generic ABS's filament_id. The printer accepted and reported back `GFB99`, so both the LCD and OrcaSlicer correctly resolved the slot to Generic ABS. The fallback was never right: the preceding default already set `tray_info_idx = convertToTrayInfoIdx(selectedPresetId)` which for any `PFUS*`/`PFSP*` setting_id returns the base setting_id itself (via the helper's `startsWith('PFUS')` branch added earlier), and the printer + both slicers round-trip that format unchanged — confirmed by existing backend integration tests (`test_configure_pfus_sent_directly`, `test_pfus_slicer_filament_used_directly`), by the print scheduler's slot-matching which already expects `P*` short-form IDs in the printer's reported `tray_info_idx` (`print_scheduler.py:910`), and by the inventory Assign Spool flow which has been sending `PFUS*` preset IDs to the printer for months. The buggy fallback *overwrote* the correct default with a generic mapping. Fixed by removing the base_id branch: when cloud detail carries a distinct `filament_id` we still prefer it, otherwise we keep the setting_id-derived default. BambuStudio Sync now resolves the custom preset cleanly; OrcaSlicer (whose user presets don't carry a `filament_id` field at all, only `inherits`) will continue to fall back to the inherited generic — that's an OrcaSlicer preset-format limitation, not something Bambuddy can fix on its side, and the behaviour is strictly not worse than before. Regression tests in `ConfigureAmsSlotModal.test.tsx` pin four paths: (1) cloud detail with `filament_id: null` → `tray_info_idx` is the `PFUS*` setting_id, (2) cloud detail with a concrete `filament_id` → that filament_id wins over the default, (3) GFS* Bambu presets skip the cloud-detail fetch entirely and still map to the short `GF*` filament_id, and (4) a 5xx / network error on the cloud-detail fetch degrades gracefully to the `PFUS*` default instead of aborting the configure flow. An end-to-end backend test (`test_configure_pfus_preserves_setting_id_pair`) locks in that both `tray_info_idx=PFUS…` and `setting_id=PFUS…` survive the HT-slot `POST /slots/{ams}/{tray}/configure` path untouched. Thanks to @mrnoisytiger for the detailed browser-console / network / backend-log diagnostic data that isolated the fallback path, and for sharing the OrcaSlicer preset JSON that showed the missing `filament_id` field.
|
||||
- **Single Malformed `rgba` Bricks the Entire Filaments Inventory Page** ([#1055](https://github.com/maziggy/bambuddy/issues/1055)) — A user's Filaments page went blank and "Add Spool" became a no-op with no visible error. The backend was returning HTTP 500 from `GET /api/v1/inventory/spools` with `fastapi.exceptions.ResponseValidationError: rgba → 'FFFFFFF' should match pattern '^[0-9A-Fa-f]{8}$'` — a single legacy spool row had a 7-char rgba (missing one trailing `F`) and Pydantic's strict pattern on `SpoolResponse` refused to serialize the whole list because of it. Root cause spans three layers: (1) `SpoolUpdate` had no rgba pattern constraint, so PATCH calls could plant malformed values straight into the DB (`SpoolCreate` did validate, but only on initial create); (2) the `ColorSection` hex input's onChange ternary `val.length <= 6 ? 'FF' : ''` silently emitted 7-char strings for 5-char or 7-char typed input (5 chars + `FF` alpha = 7 chars; 7 chars got no alpha appended at all), which then flowed to the unvalidated PATCH endpoint; (3) `SpoolResponse` inherited the same pattern as `SpoolCreate`, so any malformed row already in the DB exploded the entire list endpoint on serialize even though write-side validation was the right place for the check. Fixed on all three layers: `SpoolUpdate.rgba` now carries the same `^[0-9A-Fa-f]{8}$` pattern as `SpoolCreate`, so PATCH requests with malformed rgba are rejected with 422 at the boundary. The hex input always emits a fully-formed 8-char RRGGBBAA on every keystroke — 8-char paste passes through, 7-char drops the stray char, shorter input is right-padded with `'0'` and given FF alpha. `SpoolResponse.rgba` is now an unconstrained `Optional[str]`: the pattern belongs on request schemas where Pydantic can reject bad input, not on responses where it turns a single bad row into a total page failure. A legacy malformed row still appears in the UI (the color just renders as whatever browser default applies) but the user can see, edit, and delete it instead of having to hand-edit SQLite. Backend tests cover all three schema contracts (16 cases across `SpoolCreate` accept/reject, `SpoolUpdate` accept/reject, `SpoolResponse` lenient-tolerance on 7-char / null / garbage). Frontend tests cover the hex-input normalization for every input length 0–8 plus non-hex strip-and-pad. Thanks to @fdsghy4a for the end-to-end debugging and for locating the exact malformed row in their DB.
|
||||
- **Printer-Card "Print" Button Leaves Transient Copy in File Manager** ([#730](https://github.com/maziggy/bambuddy/issues/730)) — The "Print" button on a printer card (and the equivalent drag-drop-onto-card flow) was silently uploading the chosen file into the Library file manager as a side effect before printing. Root cause is structural: the frontend opened `FileUploadModal` to persist the file as a `LibraryFile`, then `PrintModal` dispatched a library print through `POST /library/files/{id}/print`, which uses the LibraryFile as the source for both the archive copy and the FTP upload to the printer. When the dispatch finished, both the `LibraryFile` row and its disk file in `data/library/` were left behind, so every one-off Direct-Print accumulated an unwanted File Manager entry that the user had to find and delete manually. The other three print entry points are untouched: Archive "Reprint" never involved the library, and File Manager "Print" / Project Detail "Print" are paths where the user deliberately put the file in the library, so their entries are preserved. `POST /library/files/{id}/print` now accepts an optional `cleanup_library_after_dispatch` boolean. When true, `_run_print_library_file` stages the LibraryFile row for deletion in the same transaction as the archive insert (so a mid-flight FTP or `start_print` failure rolls back both at once, leaving no orphan), commits together, then unlinks the library disk file and thumbnail from disk after commit succeeds. External library files (`is_external = True`, pointing at user-managed folders outside Bambuddy's control) are never touched regardless of the flag. The Printers-page Direct-Print flow is the only caller that sends `true`; every other `api.printLibraryFile` call site leaves the flag unset so default-False preserves their library entries. Added two unit tests at the enqueue level (default-false + flag-propagates-true), two integration tests at the endpoint level (default-false + forwards-true + cleanup flag never leaks into the MQTT options dict), and two frontend tests on `PrintModal` guarding that `cleanupLibraryAfterDispatch` only forwards when explicitly set — so future File Manager / Project Detail entry points can't accidentally inherit the Direct-Print semantics. Thanks to @3823u44238 for flagging the surprising side effect.
|
||||
- **Direct / File Manager / Library Prints Still Unattributed to User** ([#730](https://github.com/maziggy/bambuddy/issues/730)) — The 0.2.3.1 fix (commit `f03d0c4c`) plumbed the authenticated user from `POST /library/files/{id}/print` into the background-dispatch job object, but the dispatcher itself never read it back out: `_run_print_library_file` called `ArchiveService.archive_print()` without the `created_by_id` parameter and never called `printer_manager.set_current_print_user()`. Net effect: direct prints from the printer-card "Print" button, File Manager prints, and Library prints all continued to land archives with `created_by_id = NULL` (invisible to the per-user stats filter), and the post-print email notification had no user to target. The dispatcher now forwards `job.requested_by_user_id` to the archive at creation time and registers the current-print user after `start_print` succeeds — matching the reprint path's behaviour. Reprint-from-Archive attribution is a separate bug (the reprint reuses the source archive row as-is, so a NULL `created_by_id` stays NULL) and is tracked on #730. Thanks to @3823u44238 for the thorough end-to-end retest.
|
||||
|
||||
@@ -803,6 +803,50 @@ class TestConfigureAMSSlotAPI:
|
||||
call_kwargs = mock_client.ams_set_filament_setting.call_args
|
||||
assert call_kwargs.kwargs["tray_info_idx"] == "GFG99"
|
||||
|
||||
@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.
|
||||
|
||||
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.
|
||||
"""
|
||||
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/128/0/configure",
|
||||
params={
|
||||
"tray_info_idx": "PFUSa8fb76f9733e3c",
|
||||
"tray_type": "ABS",
|
||||
"tray_sub_brands": "Sting3D ABS",
|
||||
"tray_color": "000000FF",
|
||||
"nozzle_temp_min": 240,
|
||||
"nozzle_temp_max": 280,
|
||||
"setting_id": "PFUSa8fb76f9733e3c",
|
||||
},
|
||||
)
|
||||
|
||||
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["setting_id"] == "PFUSa8fb76f9733e3c"
|
||||
# Explicitly assert no generic-collapse happened for this HT slot.
|
||||
assert call_kwargs.kwargs["tray_info_idx"] != "GFB99"
|
||||
|
||||
|
||||
class TestSkipObjectsAPI:
|
||||
"""Integration tests for skip objects endpoints."""
|
||||
|
||||
@@ -185,24 +185,119 @@ describe('ConfigureAmsSlotModal', () => {
|
||||
expect(colorInput).toHaveValue('Red');
|
||||
});
|
||||
|
||||
it('derives tray_info_idx from base_id when filament_id is null', async () => {
|
||||
// Mock the detail API to return base_id but no filament_id
|
||||
it('sends PFUS setting_id as tray_info_idx when cloud detail has filament_id: null (#1053)', 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.
|
||||
(api.getCloudSettingDetail as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
filament_id: null,
|
||||
base_id: 'GFSL05_09',
|
||||
base_id: 'GFSB99_07',
|
||||
name: '# Overture Matte PLA @BBL H2D',
|
||||
});
|
||||
|
||||
render(<ConfigureAmsSlotModal {...defaultProps} />);
|
||||
const slotInfo = {
|
||||
...defaultProps.slotInfo,
|
||||
savedPresetId: 'PFUScd84f663d2c2ef',
|
||||
};
|
||||
render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);
|
||||
|
||||
// Wait for presets to load
|
||||
await waitFor(() => {
|
||||
expect(api.getCloudSettings).toHaveBeenCalled();
|
||||
expect(screen.getByText('# Overture Matte PLA @BBL H2D')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Select a user preset (one without filament_id)
|
||||
// Find and click the preset - this would require the preset to be in the list
|
||||
// The actual tray_info_idx derivation happens during the configure mutation
|
||||
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];
|
||||
// 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');
|
||||
expect(payload.setting_id).toBe('PFUScd84f663d2c2ef');
|
||||
});
|
||||
|
||||
it('uses cloud detail filament_id when present', async () => {
|
||||
(api.getCloudSettingDetail as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
filament_id: 'P285e239',
|
||||
base_id: 'GFSB99_07',
|
||||
name: '# Overture Matte PLA @BBL H2D',
|
||||
});
|
||||
|
||||
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];
|
||||
expect(payload.tray_info_idx).toBe('P285e239');
|
||||
expect(payload.setting_id).toBe('PFUScd84f663d2c2ef');
|
||||
});
|
||||
|
||||
it('sends short GF filament_id for Bambu GFS* presets (cloud detail not consulted)', async () => {
|
||||
// Bambu-provided presets (GFS*) convert the setting_id → filament_id locally.
|
||||
// The cloud detail endpoint must NOT be consulted for them; the rewrite that
|
||||
// fixed #1053 preserves this pre-existing shortcut.
|
||||
const slotInfo = {
|
||||
...defaultProps.slotInfo,
|
||||
savedPresetId: 'GFSL05_09',
|
||||
};
|
||||
render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Bambu PLA Basic @BBL X1C')).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('GFL05');
|
||||
expect(payload.setting_id).toBe('GFSL05_09');
|
||||
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.
|
||||
(api.getCloudSettingDetail as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error('cloud unreachable')
|
||||
);
|
||||
|
||||
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];
|
||||
expect(payload.tray_info_idx).toBe('PFUScd84f663d2c2ef');
|
||||
expect(payload.setting_id).toBe('PFUScd84f663d2c2ef');
|
||||
});
|
||||
|
||||
it('renders configure slot button', async () => {
|
||||
|
||||
@@ -369,19 +369,18 @@ export function ConfigureAmsSlotModal({
|
||||
trayInfoIdx = builtinFilamentId!;
|
||||
settingId = '';
|
||||
} else {
|
||||
// Get tray_info_idx: for user presets, fetch detail to get filament_id or derive from base_id
|
||||
trayInfoIdx = convertToTrayInfoIdx(selectedPresetId);
|
||||
settingId = selectedPresetId;
|
||||
|
||||
// For user presets (not starting with GF), fetch the detail to get the real filament_id
|
||||
// User cloud presets may carry a distinct filament_id in the cloud detail
|
||||
// (e.g. "P285e239"); prefer it when present. Never fall back to base_id —
|
||||
// that collapses custom presets to the inherited generic's filament_id and
|
||||
// makes the slicer resolve the slot to "Generic …" instead (#1053).
|
||||
if (!selectedPresetId.startsWith('GFS')) {
|
||||
try {
|
||||
const detail = await api.getCloudSettingDetail(selectedPresetId);
|
||||
if (detail.filament_id) {
|
||||
trayInfoIdx = detail.filament_id;
|
||||
} else if (detail.base_id) {
|
||||
trayInfoIdx = convertToTrayInfoIdx(detail.base_id);
|
||||
console.log(`Derived tray_info_idx from base_id: ${detail.base_id} -> ${trayInfoIdx}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch preset detail for filament_id:', e);
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -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-CASdUlGi.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-D-cysdnw.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CkAOuJaW.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user