mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-20 00:25:22 +02:00
fix(inventory): assign-spool toast no longer claims AMS configured when slot was empty (#1680)
Backend correctly defers MQTT configuration when the AMS slot is empty
at assign time (state ∈ {9, 10}) — firmware drops the push silently;
on_ams_change replays the config once a spool is detected. The
response carries pending_config=true to communicate that. The
printer-card AssignSpoolModal was ignoring the flag and always
showing "Spool assigned and AMS slot configured" — the SpoolBuddy
modal has handled this since it shipped. Now mirrors the same
branch and shows "Assigned. Slot will configure when you insert
the spool." when pending_config is true.
This commit is contained in:
@@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
## [0.2.5b1] - Unreleased
|
||||
|
||||
### Fixed
|
||||
- **"Assign Spool" no longer claims the AMS slot was configured when it wasn't (#1680, reported by @kleinwareio)** — Reporter clicked Assign Spool from the printer card for AMS-B slot 4 while that slot was empty. The toast said "Spool assigned and AMS slot configured" but the AMS card kept showing slot 4 as Empty. **Root cause: misleading toast on the empty-slot deferred-config path.** The backend (`inventory.py:1385-1405`) deliberately skips the MQTT `ams_filament_setting` publish when the AMS reports an empty tray state (state ∈ {9, 10}) because Bambu firmware silently drops the push for empty slots — there's no point sending a command the printer will discard. The assignment row is persisted with `pending_config=true`, and `on_ams_change` (`main.py:1031-1054`) re-fires the full configuration the moment the AMS reports a non-empty fingerprint in that slot. The flow is correct; the success log line `Pre-configured assignment: spool 16 → printer 1 AMS1-T3 (slot empty, will configure on insert)` confirms the backend did exactly that. **But the frontend ignored the response flag.** `AssignSpoolModal.tsx:153` always called `showToast(t('inventory.assignSuccess'), 'success')` — the wording "Spool assigned and AMS slot configured" — regardless of whether the backend actually configured the slot or deferred. The sibling SpoolBuddy modal (`spoolbuddy/AssignToAmsModal.tsx:212-226`) already branched on `pending_config` and showed a distinct "Slot will configure when you insert the spool" message; the printer-card modal was just never updated to match. **Fix:** `AssignSpoolModal.tsx` now reads `newAssignment.pending_config` and picks between `'inventory.assignSuccess'` (slot configured immediately) and the new `'inventory.assignPendingInsert'` ("Assigned. Slot will configure when you insert the spool.") key. Spoolman-mode branch unchanged — the Spoolman backend route always sends the MQTT push (no pending_config flag is exposed) and the SpoolBuddy modal's existing comment documents that. **i18n:** new `inventory.assignPendingInsert` key in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), translations copied verbatim from the existing parallel `spoolbuddy.modal.assignPendingInsert` entries so the message reads identically across the app. No English fallbacks per the project's hard rule; i18n parity check confirms 5066 leaves × 11 locales. **Tests:** 2 new in `AssignSpoolModal.test.tsx` — `shows the pending-insert toast when backend returns pending_config=true (#1680)` pins the new branch (slot-was-empty case the reporter hit), and `shows the configured toast when backend returns pending_config=false (#1680)` is the counterpart regression guard so a future refactor can't silently mark every assign as pending. Both also assert the WRONG toast is NOT also called (defense against accidental double-toast). 16/16 AssignSpoolModal tests pass; frontend build clean; ESLint clean.
|
||||
|
||||
- **Restarting Bambuddy mid-print no longer marks the live archive as "cancelled / aborted" + duplicates it + double-counts filament (#1679, reported by @IndividualGhost1905)** — Reporter on X1C, daily build `v0.2.5b1-daily.20260607`: a print was running, the host was restarted (planned reboot / power outage / watchtower image update), and Bambuddy's printer card showed the print as **cancelled** while the printer continued printing happily. Print log showed `aborted` for that row, filament usage was deducted at the cancellation moment (48.6 g / 5 % in the supplied screenshots), and when the print actually finished a *second* archive was created and filament was deducted *again*. Net effect: filament inventory off by the entire print weight, statistics showing one "user-cancelled" entry alongside one "completed" entry for the same physical print. Second confirmed hit from the same reporter, plus a corroborating comment from @Arn0uDz on watchtower-driven restarts. **Root cause: connected-edge reconciliation fired on a bare MQTT-connected state that had no real data yet.** On Bambuddy startup, a fresh `BambuMQTTClient` is constructed with `PrinterState` defaults — most importantly `state.state = "unknown"` and `state.subtask_name = ""`. The MQTT `_on_connect` callback (`bambu_mqtt.py:668-669`) broadcasts `on_state_change(self.state)` *immediately* after the broker accepts the connection — BEFORE the `_request_push_all` round-trips with the printer's real status. `on_printer_status_change` (`main.py:825`) sees `state.connected=True` flip on the connected-edge, spawns `reconcile_stale_active_prints` for that printer. The reconcile walks every archive in `status="printing"`, calls `_is_active_archive_stale` (`main.py:3352`) — which sees `state.state="UNKNOWN"` (skips the IDLE/FINISH/FAILED branch), then `state.subtask_name=""` (matches trigger 3, "printer subtask_name empty") and **returns stale**. A synthesised `aborted` PRINT COMPLETE fires for every in-flight archive on every printer, clears `_active_prints`, and when the real PRINT COMPLETE finally arrives at print end, `_active_prints` doesn't have the entry, so a brand-new archive row is created instead of overwriting the synthesised one. The pre-existing comment at `_is_active_archive_stale` ("the next real PRINT COMPLETE would have overwritten the status anyway") was wrong: the reactive completion handler uses `_active_prints` for lookup, not a join on filename/subtask_id, so the original row stays cancelled and a duplicate is born. Timing-dependent in practice — on hosts where the printer's first `push_status` response wins the race against the reconcile background task, state is real and reconcile doesn't false-positive; on slower hosts or busy MQTT brokers, the bare-connect-edge fires first and the bug hits. The reporter is on a slower-race host and saw it twice. **Fix: two-layer guard.** (1) Primary: `on_printer_status_change` now gates the reconcile spawn on `state.state` being a real value — `state_known = bool(state.state) and state.state.upper() not in ("", "UNKNOWN")` — so reconcile doesn't fire until the first `push_status` updates `state.state` to a real Bambu firmware value (RUNNING / IDLE / FINISH / PREPARE / SLICING / PAUSE / FAILED). When that real push arrives, `on_printer_status_change` fires again, the connected-edge flag is still `False` (we never set it), and reconcile runs against actual evidence. The existing #1542 mechanism — synthesising a missed PRINT COMPLETE for prints that finished during a disconnect window — keeps working: if the printer reports `IDLE` on its first real push after reconnect, reconcile catches it the way it always did. (2) Belt-and-braces: `_is_active_archive_stale` now returns `(False, "")` when `state.state` is empty / `"unknown"` / `None`, regardless of the subtask fields. Strictly more conservative than the previous behaviour; only suppresses the degenerate-input false positive. Any future caller that bypasses the primary gate still can't synthesise an aborted completion from defaults. **Tests:** `test_reconcile_stale_active_prints.py` 26 cases (up from 21) — new parametrize `test_pre_push_state_returns_not_stale_even_with_empty_subtask` pins all five degenerate forms (`"unknown"`, `"UNKNOWN"`, `"Unknown"`, `""`, `None`) and asserts none triggers stale even with empty `subtask_id` + empty `subtask_name`. The existing #1542 regression coverage stays green — terminal-state, subtask-id-mismatch, and empty-subtask-name-under-RUNNING all still report stale on real state pushes. Full backend suite: 3836 / 3836 pass. ruff clean.
|
||||
|
||||
- **Print queue no longer wedges in "Currently Printing" when a printer accepts `project_file` but never starts (#1678, reported by @kleinwareio)** — Reporter on two P1S, one was power-cycled mid-print and came back online; from then on Bambuddy showed the next queue item as "Currently Printing" at 0% while the printer card showed "Idle / Ready to print". The same file also re-appeared in the Queued list as Pending after the user resubmitted. Only restarting the Bambuddy container ever recovered it. Support log + screenshots confirm: at dispatch time MQTT `project_file` was ACK'd, printer pushed `gcode_state=IDLE, gcode_file=<our-file>, subtask_id=<our-submission-id>` — i.e. the file landed on the printer but the printer never transitioned IDLE → PREPARE → RUNNING. **Root cause: `_watchdog_print_start` returned SUCCESS as soon as `subtask_id` advanced.** The subtask_id-as-pickup-signal was added for H2D, which can sit at `FINISH` for ~50 s after accepting `project_file` before flipping to PREPARE (#1078) — but it's strictly a "command landed" signal, not "actually printing". When the printer accepts the file but then wedges (cloud+LAN re-auth dance after a power cycle, old firmware, partial network outage), the watchdog returned success, the queue row stayed at `status='printing'`, the in-memory `_expected_prints` entry stayed registered (TTL is 2 hours and only clears the dict, not the DB row), and every subsequent queue item was blocked because the printer was still "in flight". This reporter's firmware (01.07.00.00, current is 01.08.x+) and `bambu_cloud_token`-enabled cloud+LAN mode make the post-power-cycle wedge measurably more likely on their box, but the queue-wedge bug applies to any printer that accepts a file but stalls before starting. **Fix: split the watchdog into two phases.** Phase A (up to `timeout`, default 90 s, unchanged behaviour) waits for either an active-state transition OR a `subtask_id` advance — if neither happens the publish was lost on a half-broken MQTT session (#887/#936) and we revert + force-reconnect (the original #967 recovery path). Phase B (new, up to `phase_b_timeout`, default 180 s) only runs when Phase A exited via subtask_id-alone: keep watching for the active-state transition. 180 s is ~3.5× the worst observed H2D FINISH → PREPARE delay (#1078), so the H2D path stays green. If Phase B times out the queue item is reverted to `pending` so the user can retry without restarting Bambuddy — and Phase B explicitly does NOT force a MQTT reconnect because subtask_id-advance proves the project_file landed and a forced reconnect mid-parse triggers 0500_4003 (#1150). Phase A's existing `gcode_file`-changed discriminator (#1150) stays put for the no-subtask-id-advance case. **Tests:** `test_scheduler_watchdog.py` 14 cases (up from 13) — the #1078 H2D regression test rewritten to step the status through Phase A (subtask_id advance with state=FINISH) then Phase B (state flips to RUNNING) and pin success; new `test_reverts_when_subtask_advanced_but_state_never_active` pins the #1678 wedge case (subtask_id advances, state stays IDLE for the full Phase B window → revert + NO force_reconnect call); new `test_default_phase_b_timeout_is_180_seconds` pins the new default so a future refactor doesn't silently shrink the H2D headroom. Existing #967 / #1150 / #1370 / disconnect / fallback / discriminator regression coverage all stays green. Wider scheduler + queue + dispatch test surface (305 tests) stays green; ruff clean.
|
||||
|
||||
@@ -18,6 +18,15 @@ vi.mock('../../api/client', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const mockShowToast = vi.fn();
|
||||
vi.mock('../../contexts/ToastContext', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
|
||||
return {
|
||||
...actual,
|
||||
useToast: () => ({ showToast: mockShowToast }),
|
||||
};
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
@@ -323,6 +332,88 @@ describe('AssignSpoolModal', () => {
|
||||
expect(api.refreshPrinterStatus).toHaveBeenCalledWith(7);
|
||||
});
|
||||
});
|
||||
|
||||
// #1680: backend returns `pending_config=true` when the AMS slot was empty
|
||||
// at assign time (firmware drops the MQTT push silently; on_ams_change
|
||||
// re-fires the config when filament is detected). The toast MUST reflect
|
||||
// that the slot hasn't been configured yet — saying "AMS slot configured"
|
||||
// reads as a lie and is what the reporter hit.
|
||||
it('shows the pending-insert toast when backend returns pending_config=true (#1680)', async () => {
|
||||
const { default: userEvent } = await import('@testing-library/user-event');
|
||||
const user = userEvent.setup();
|
||||
|
||||
(api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([manualSpool]);
|
||||
(api.assignSpool as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: 1, spool_id: 1, printer_id: 7, ams_id: 0, tray_id: 0,
|
||||
pending_config: true,
|
||||
});
|
||||
|
||||
render(
|
||||
<AssignSpoolModal
|
||||
{...defaultProps}
|
||||
printerId={7}
|
||||
trayInfo={{ type: 'PLA', material: 'PLA', profile: 'PLA', color: 'FF0000', location: 'AMS 1 - Slot 1' }}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByText(/Polymaker/));
|
||||
await user.click(screen.getByRole('button', { name: /assign spool/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Slot will configure when you insert the spool'),
|
||||
'success',
|
||||
);
|
||||
});
|
||||
// And the "AMS slot configured" toast must NOT also have fired.
|
||||
expect(mockShowToast).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('AMS slot configured'),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
// Counterpart guard: when the backend did configure the slot
|
||||
// (pending_config=false or absent), the existing success toast still
|
||||
// fires. Without this regression test, the #1680 fix could silently
|
||||
// overshoot and label every assign as pending.
|
||||
it('shows the configured toast when backend returns pending_config=false (#1680)', async () => {
|
||||
const { default: userEvent } = await import('@testing-library/user-event');
|
||||
const user = userEvent.setup();
|
||||
|
||||
(api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([manualSpool]);
|
||||
(api.assignSpool as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: 1, spool_id: 1, printer_id: 7, ams_id: 0, tray_id: 0,
|
||||
pending_config: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<AssignSpoolModal
|
||||
{...defaultProps}
|
||||
printerId={7}
|
||||
trayInfo={{ type: 'PLA', material: 'PLA', profile: 'PLA', color: 'FF0000', location: 'AMS 1 - Slot 1' }}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByText(/Polymaker/));
|
||||
await user.click(screen.getByRole('button', { name: /assign spool/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining('AMS slot configured'),
|
||||
'success',
|
||||
);
|
||||
});
|
||||
expect(mockShowToast).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('Slot will configure when you insert'),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AssignSpoolModal — Spoolman enabled (T-Gap 7)', () => {
|
||||
|
||||
@@ -150,7 +150,19 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
|
||||
nudgePrinterRepublish();
|
||||
showToast(t('inventory.assignSuccess'), 'success');
|
||||
// When the AMS slot was empty at assign time (tray_state ∈ {9, 10}), the
|
||||
// backend persists the assignment but deliberately skips the MQTT
|
||||
// `ams_filament_setting` push because Bambu firmware drops it silently
|
||||
// for empty slots. `on_ams_change` re-fires the configuration once a
|
||||
// spool is detected in the slot (#1680). The success-but-pending case
|
||||
// gets a distinct toast so the user understands the slot hasn't been
|
||||
// configured on the printer yet — saying "AMS slot configured" reads
|
||||
// as a lie in that state. Mirror of `spoolbuddy/AssignToAmsModal.tsx`,
|
||||
// which has handled this since the SpoolBuddy assign flow shipped.
|
||||
const toastKey = newAssignment.pending_config
|
||||
? 'inventory.assignPendingInsert'
|
||||
: 'inventory.assignSuccess';
|
||||
showToast(t(toastKey), 'success');
|
||||
setShowMismatchConfirm(false);
|
||||
setPendingAssignId(null);
|
||||
setMismatchDetails(null);
|
||||
|
||||
@@ -3731,6 +3731,7 @@ export default {
|
||||
assignSpool: 'Spule zuweisen',
|
||||
unassignSpool: 'Zuweisung aufheben',
|
||||
assignSuccess: 'Spule zugewiesen und AMS-Slot konfiguriert',
|
||||
assignPendingInsert: 'Zugewiesen. Slot wird beim Einsetzen der Spule konfiguriert.',
|
||||
assignFailed: 'Spulenzuweisung fehlgeschlagen',
|
||||
selectSpool: 'Wählen Sie eine Spule für diesen Slot',
|
||||
assigned: 'Zugewiesen',
|
||||
|
||||
@@ -3739,6 +3739,7 @@ export default {
|
||||
assignSpool: 'Assign Spool',
|
||||
unassignSpool: 'Unassign',
|
||||
assignSuccess: 'Spool assigned and AMS slot configured',
|
||||
assignPendingInsert: 'Assigned. Slot will configure when you insert the spool.',
|
||||
assignFailed: 'Failed to assign spool',
|
||||
selectSpool: 'Select a spool to assign to this slot',
|
||||
assigned: 'Assigned',
|
||||
|
||||
@@ -3735,6 +3735,7 @@ export default {
|
||||
assignSpool: 'Asignar bobina',
|
||||
unassignSpool: 'Desasignar',
|
||||
assignSuccess: 'Bobina asignada y ranura del AMS configurada',
|
||||
assignPendingInsert: 'Asignada. La ranura se configurará cuando inserte la bobina.',
|
||||
assignFailed: 'Error al asignar la bobina',
|
||||
selectSpool: 'Seleccione una bobina para asignar a esta ranura',
|
||||
assigned: 'Asignada',
|
||||
|
||||
@@ -3719,6 +3719,7 @@ export default {
|
||||
assignSpool: 'Assigner Bobine',
|
||||
unassignSpool: 'Désassigner',
|
||||
assignSuccess: 'Bobine assignée et slot AMS configuré',
|
||||
assignPendingInsert: 'Assigné. Le slot sera configuré lors de l\'insertion de la bobine.',
|
||||
assignFailed: 'Échec assignation',
|
||||
selectSpool: 'Choisir une bobine pour ce slot',
|
||||
assigned: 'Assigné',
|
||||
|
||||
@@ -3718,6 +3718,7 @@ export default {
|
||||
assignSpool: 'Assegna Bobina',
|
||||
unassignSpool: 'Scollega',
|
||||
assignSuccess: 'Bobina assegnata e slot AMS configurato',
|
||||
assignPendingInsert: 'Assegnato. Lo slot verrà configurato all\'inserimento della bobina.',
|
||||
assignFailed: 'Assegnazione bobina fallita',
|
||||
selectSpool: 'Seleziona una bobina da assegnare a questo slot',
|
||||
assigned: 'Assegnato',
|
||||
|
||||
@@ -3730,6 +3730,7 @@ export default {
|
||||
assignSpool: 'スプールを割り当て',
|
||||
unassignSpool: '割り当て解除',
|
||||
assignSuccess: 'スプールを割り当て、AMSスロットを設定しました',
|
||||
assignPendingInsert: '割り当てました。スプールを挿入したときにスロットが設定されます。',
|
||||
assignFailed: 'スプールの割り当てに失敗しました',
|
||||
selectSpool: 'このスロットに割り当てるスプールを選択',
|
||||
assigned: '割り当て済み',
|
||||
|
||||
@@ -3519,6 +3519,7 @@ export default {
|
||||
assignSpool: '스풀 할당',
|
||||
unassignSpool: '할당 해제',
|
||||
assignSuccess: '스풀 할당 및 AMS 슬롯 구성됨',
|
||||
assignPendingInsert: '할당됨. 스풀을 삽입하면 슬롯이 구성됩니다.',
|
||||
assignFailed: '스풀 할당 실패',
|
||||
selectSpool: '이 슬롯에 할당할 스풀 선택',
|
||||
assigned: '할당됨',
|
||||
|
||||
@@ -3718,6 +3718,7 @@ export default {
|
||||
assignSpool: 'Atribuir Carretel',
|
||||
unassignSpool: 'Desatribuir',
|
||||
assignSuccess: 'Carretel atribuído e slot AMS configurado',
|
||||
assignPendingInsert: 'Atribuído. O slot será configurado quando você inserir o carretel.',
|
||||
assignFailed: 'Falha ao atribuir carretel',
|
||||
selectSpool: 'Selecione um carretel para atribuir a este slot',
|
||||
assigned: 'Atribuído',
|
||||
|
||||
@@ -3718,6 +3718,7 @@ export default {
|
||||
assignSpool: 'Makara Ata',
|
||||
unassignSpool: 'Atamayı Kaldır',
|
||||
assignSuccess: 'Makara atandı ve AMS yuvası yapılandırıldı',
|
||||
assignPendingInsert: 'Atandı. Makarayı yerleştirdiğinizde yuva yapılandırılacak.',
|
||||
assignFailed: 'Makara atanamadı',
|
||||
selectSpool: 'Bu yuvaya atamak için bir makara seçin',
|
||||
assigned: 'Atandı',
|
||||
|
||||
@@ -3718,6 +3718,7 @@ export default {
|
||||
assignSpool: '分配耗材',
|
||||
unassignSpool: '取消分配',
|
||||
assignSuccess: '耗材已分配,AMS 槽位已配置',
|
||||
assignPendingInsert: '已分配。插入耗材后将配置槽位。',
|
||||
assignFailed: '分配耗材失败',
|
||||
assignMismatchTitle: '材料不匹配',
|
||||
assignMismatchMessage: '所选线轴材料 "{{spoolMaterial}}" 与 {{location}} 的料槽材料 "{{trayMaterial}}" 不匹配。仍要分配吗?',
|
||||
|
||||
@@ -3718,6 +3718,7 @@ export default {
|
||||
assignSpool: '分配耗材',
|
||||
unassignSpool: '取消分配',
|
||||
assignSuccess: '耗材已分配,AMS 槽位已設定',
|
||||
assignPendingInsert: '已分配。插入耗材後將設定槽位。',
|
||||
assignFailed: '分配耗材失敗',
|
||||
assignMismatchTitle: '材料不符',
|
||||
assignMismatchMessage: '所選料盤材料 "{{spoolMaterial}}" 與 {{location}} 的料槽材料 "{{trayMaterial}}" 不符。仍要分配嗎?',
|
||||
|
||||
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-B-fcW9HO.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CqscW2CN.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DgecYhis.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user