diff --git a/CHANGELOG.md b/CHANGELOG.md index 7083cbe90..3683ad199 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ All notable changes to Bambuddy will be documented in this file. - **AMS Filament Backup status + control on the printer card** — New per-printer surface that mirrors BambuStudio's "AMS Filament Backup" checkbox (the per-AMS auto-switch to a second matching spool when one runs out). Until now Bambuddy had no read or write access to the printer-side backup state; the only way to change it was via the slicer or the printer's touchscreen, and Bambuddy's "Prefer lowest remaining filament" preference was ignorant of it (see the linked Fixed entry for #1766 — the two ship together). **Backend — parse the state.** New tri-state `PrinterState.ams_filament_backup: bool | None` populated from bit 18 of the top-level `print.cfg` hex string on every push_status (`bambu_mqtt.py::_process_message` ~line 1037). New module-level helper `parse_ams_filament_backup_from_cfg()` returns `None` on absent / non-hex / non-string input so old-protocol families (A1 / A1 Mini, which emit no `cfg`) preserve today's behaviour — the tri-state default applies the dispatcher's sort, never coerces to OFF, so A1 users see zero regression. Verified against OrcaSlicer source (`DeviceManager.cpp:4961` `SetAutoRefillEnabled(get_flag_bits(cfg, 18))`) and a live H2D ON/OFF capture during this work — the cfg flips exactly between `C0340FC219` (bit 18 set, ON) and `C0340BC219` (bit 18 clear, OFF), only the fifth nibble changing. **Backend — toggle.** New `POST /printers/{id}/ams-backup?enabled=` route gated on `Permission.PRINTERS_CONTROL` calls `client.set_ams_filament_backup(enabled)` which routes through `_set_print_option("auto_switch_filament", enabled)`. The MQTT payload shape `{"print": {"command": "print_option", "auto_switch_filament": , "sequence_id": "20000"}}` was verified by capturing BambuStudio's own command on the request topic with a temporary outbound diagnostic logger — single field at a time, never bundled with other `print_option` flags, so we never clobber other state. Optimistic local state update lives inside `_set_print_option` immediately after `_client.publish(...)`. **Hold-timer guard** (`_xcam_hold_start["print_option_auto_switch_filament"]`, 3 s window, mirrors the existing xcam pattern for spaghetti / first-layer detector settings): when the user just toggled via Bambuddy's badge, the next 1-2 push_status frames may still carry the printer's PRE-toggle cfg before the firmware reflects the change — without this gate the badge would flicker ON→OFF→ON on every toggle. The hold fires only when Bambuddy itself initiated the change; Studio-side or printer-display toggles propagate immediately. **Backend — inventory-remain endpoint.** New `GET /printers/{id}/inventory-remain` route exposes the same `Map` the dispatcher uses (via the existing `_build_inventory_remain_overrides` helper), so PrintModal's client-side "Prefer Lowest Remaining Filament" sort can apply the same two-tier ordering the backend would on dispatch. Internal AND Spoolman modes both work uniformly via the existing helper's mode branch — external / VT slots excluded, negative grams clamped to `max(0.0, label - used)`. JSON-keyed-as-string convention so the wire format is clean; client coerces back to Number on receive. Permission: `Permission.PRINTERS_READ` (same as reading printer status). **REST + WS response surface.** `printer_state_to_dict` and the `PrinterStatusResponse` Pydantic schema both extended with the new field; the printer's REST `/printers/{id}` response carries `ams_filament_backup`. `state.ams_filament_backup` added to the `status_key` dedup tuple in `main.py:1101` so backup toggles trigger an immediate WS broadcast and clients see live state changes whether the toggle came from Bambuddy, BambuStudio, or the printer's touchscreen. **Frontend — printer card badge.** Small icon button in the "Filaments" section header on each printer card (`PrintersPage.tsx`), placed beside the section label so the printer-wide nature reads correctly (the cfg bit is one per printer, not per AMS unit — the original draft put it per-AMS row, which would have duplicated the same state on multi-AMS printers and looked confusing). Three states: ON = blue circular-arrow icon (`Repeat` from lucide-react) on `bg-blue-500/20`; OFF = dim icon on `bg-bambu-dark`; unknown (A1 family / no cfg yet) = "?" character on dim background, click disabled. Click on a known state toggles via the new endpoint, with optimistic update and success toast (`AMS Filament Backup enabled/disabled`). The mutation invalidates BOTH `'printerStatus'` (camelCase) and `'printer-status'` (kebab-case) cache keys — the codebase has both conventions in active use (`useFilamentMapping`-related hooks use kebab, everything else uses camelCase), so only hitting one would leave PrintModal showing stale backup state if the user toggled from the printer card while the modal was open. **i18n.** 5 new keys in the `printers.amsBackup.*` namespace (`titleOn`, `titleOff`, `titleUnknown`, `toastEnabled`, `toastDisabled`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. **Tests.** 12 new backend cases — `test_bambu_mqtt_cfg_parse.py` (parser × 13: real H2D ON/OFF captures, X1C short hex, lowercase, isolated bit-18 set / clear, every malformed shape returns None safely — note: 1 case is a parametrized invalid-input set of 7 sub-cases so the test file shows 13 reported cases) and `test_bambu_mqtt.py::TestAmsFilamentBackupHoldTimer` (× 3: stale push during hold ignored, push after hold applies, same-value push during hold no-op). Two PrinterState SimpleNamespace stubs in `test_printer_offline_notification.py` and `test_printer_manager_status_broadcast.py` extended with `ams_filament_backup=None` to match the new `status_key` field; full pytest confirms no other stub needed updating. **What this does NOT do.** Cover A1 / A1 Mini: those models emit no `cfg` field in push_status so the badge shows `?` and the dispatcher's sort applies as before. Once we identify the A1-specific field (waiting on a future Discord owner with a clean ON/OFF capture) we'll populate it via a model-specific path; until then the tri-state default keeps zero regression. Affect downstream consumers of PrinterState: `mqtt_relay`, webhook routes, and Home Assistant integration enumerate fields explicitly, so adding `ams_filament_backup` doesn't change what they emit. Full backend pytest 6217/6217; full frontend vitest 2170/2170; ruff clean; `npm run build` clean; ESLint clean. +- **Per-printer Maintenance Mode toggle (#1476, requested by @IndividualGhost1905 / Ferdi SEVER)** — Operator-flipped "out of service" state per printer, surfaced as a wrench icon + amber pill on the card and a checkbox in the Edit Printer dialog. Requested for three real-world scenarios that all share the same shape: (1) parallel Bambuddy installs (dev + prod, primary + warm spare) where the printer rejects concurrent MQTT clients except one, leaving the others in a flicker-online state burning CPU and network; (2) printers under repair / awaiting spare parts that shouldn't accept queue jobs but should remain visible on the dashboard so they aren't forgotten; (3) temporary suspension during maintenance work. **What was already there, what was missing.** The backend field `Printer.is_active: bool` has shipped since the initial Bambuddy release — toggling it via `PATCH /printers/{id}` already disconnects MQTT (`printer_manager.disconnect_printer` at `printers.py:366`), stops the printer from being eligible for queue dispatch (`print_scheduler.py:520, 1588`, `print_queue.py:383`), excludes it from model-based filament lookups (`printers.py:197`), excludes it from metrics + diagnostic snapshots + scheduled-backup runs (`metrics.py:105`, `diagnostic_snapshot.py:126`, `github_backup.py:333`, `maintenance.py:457`), and is already honoured by PrinterSelector (filtered with a "show inactive" override, greyed + "(inactive)" label when shown). All three of Ferdi's use cases were structurally supported by `is_active` from day one. **The missing piece was UI exposure.** `grep is_active` on `PrintersPage.tsx` returned zero hits — no menu item, no edit field, no toggle. The only way to flip it was a direct API call. This change adds the surfaces that should have been there all along. **Card UI — replacement, not addition.** Per Ferdi-conversation feedback, the maintenance state replaces the print-status / cover-image container rather than stacking above it, so card heights stay identical across the grid: in expanded mode the same `` header renders an amber panel (wrench icon + "In Maintenance" + subtitle + Exit button) where the cover + progress would normally be; in compact mode a single amber pill replaces the progress bar. The header connection pill is also swapped — instead of the red "Offline" pill (which would be misleading because the disconnect is deliberate) the card shows an amber "Maintenance" pill, and the "Run Diagnostic" CTA is suppressed (that's reserved for involuntary offline triage). HMS / Queue / Firmware status pills are still gated by `status?.connected` so they fall away naturally with the MQTT disconnect. **Three entry points.** (1) Printer card three-dot overflow menu — `Enter maintenance mode` / `Exit maintenance mode` with a wrench icon, adjacent to the Edit and Reconnect actions. (2) Exit button inside the in-card amber panel, so a user noticing the card from across the room can flip back without opening the menu. (3) Checkbox in the EditPrinterModal — `Maintenance mode` with the same subtitle as the help line, so the toggle is discoverable from the edit dialog too (the checkbox is the inverse of `is_active` because the user-facing concept is "is this in maintenance" not "is it active"). **Mid-print safety prompt.** Entering maintenance mode on a printer in `RUNNING` / `PAUSE` state triggers a confirmation dialog before the toggle fires — disconnecting MQTT mid-print stops progress tracking + completion notifications for the in-flight job, which is usually NOT what the operator wants (they probably meant "after this print finishes"). Idle / FINISH / FAILED states skip the dialog and toggle directly. **What this does NOT change.** No backend change (`is_active` was already wired everywhere); no new permission (uses existing `printers:update`); no behaviour change for any other consumer (queue dispatch, scheduler, metrics, picker, backup — all already honoured `is_active`). The card stays visible on the Printers page (greyed temps/controls/fans below the amber banner) so the printer doesn't disappear from the operator's mental map — Ferdi explicitly wanted to remember it's there. Doesn't auto-pause Smart Plug logic or notification providers (would be a sensible follow-up if Ferdi asks; out of scope here to keep the diff bounded to "expose the existing gate"). The scheduled-maintenance dashboard at `/maintenance` (interval-tracked rod-cleaning / lube / belt tasks via the existing `MaintenanceHistory` and `PrinterMaintenance` models) is conceptually adjacent but operationally distinct — the dashboard tracks "this printer is due for cleaning"; Maintenance Mode tracks "this printer is currently out of service." A future "perform maintenance task → optionally enter maintenance mode while you do it" link is the natural connection but isn't wired here. **i18n.** Twelve new keys under `printers.maintenance.*` (title / subtitle / pillLabel / exitButton / menuEnter / menuExit / toastEntered / toastExited / confirmMidPrintTitle / confirmMidPrintMessage / editFieldLabel / editFieldHelp) — real translations in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity 5228 leaves per locale, no English fallback. **Tests.** 4 new cases in `PrintersPage.test.tsx::'maintenance mode (#1476)'`: amber status panel renders with Exit button (and the regular "No active job" / "Ready to print" copy is absent — confirms the swap, not a stacked render); header pill swaps to amber Maintenance and the diagnostic CTA is suppressed; clicking Exit issues a `PATCH /printers/{id}` with `is_active: true`; active printers never show the maintenance panel. Existing test fixture (`mockPrinters`) got an explicit `is_active: true` to keep the existing 56 tests green on the new render path. **Type:** `PrinterCreate.is_active?: boolean` added to the TypeScript surface so the field flows cleanly through the existing `api.updatePrinter` helper. **Build + checks.** Full PrintersPage vitest 60/60 green; `npm run build` clean; ESLint clean; i18n parity 5228 × 11 locales green. + ### Fixed - **Mid-print AMS Backup spool-switch credited the entire print to the second spool instead of splitting the weight (#1771, reported by @biduleman)** — Reporter (P1S) forcefully started a print needing ~260 g with only 180 g remaining on the first spool; the printer correctly consumed the first spool, AMS Backup auto-switched to a same-material second spool, and finished the print. Bambuddy then attributed ALL 260 g to the second spool; the first spool was left untouched in the inventory. Reads as a usage-attribution bug; root cause is two stacking firmware-quirk bugs that produce exactly the all-to-second-spool symptom for prints without per-layer 3MF gcode. **Bug A — firmware reset of `total_layer_num`.** `bambu_mqtt.py:2135` wrote `state.total_layers = int(data["total_layer_num"])` unconditionally on every push containing the field. P1S firmware (observed; matches the pattern other models reset `layer_num` / `progress` via at print end) pushes a `total_layer_num: 0` frame at print completion. The unconditional write clobbered the slicer's actual total — by the time the usage-tracker ran a frame or two later, `state.total_layers` was 0. The existing `_last_valid_layer_num` guard at line 2127 covered the same race for `layer_num` but the equivalent guard for `total_layers` was never added. **Bug B — `usage_tracker.py:1129-1137` dumped-all-to-last fallback.** The mid-print tray-switch split path (which handles AMS Backup → second spool exactly like this scenario) has three attribution branches per segment: per-layer 3MF gcode (precise), linear by layer ratio (`total_layer_num`-based), and "remainder" (the last segment always gets `total_weight - sum_previous`). When per-layer 3MF data is unavailable (force-started prints often lack it) AND `total_layers == 0` (Bug A had just fired), the linear branch silently produced `segment_grams = 0.0` for every non-last segment — so the entire print weight collapsed onto the last segment's remainder calculation. Path 2 (AMS remain% delta) couldn't recover because (1) the just-emptied spool typically reports `remain=-1` after the empty event so the percent-delta calculation rejected it, and (2) the second spool's tray key had already been added to `handled_trays` by Bug B's misallocation, suppressing the Path 2 lookup. End result: 260 g credited to spool 2, spool 1 left at 180 g unchanged — exactly the screenshot the reporter posted. **Fix A — `bambu_mqtt.py`.** Mirror the existing `_last_valid_layer_num` shape: only overwrite `state.total_layers` when the incoming `total_layer_num` is positive, so a firmware-reset frame can't clobber the cached value. The explicit reset to 0 on new print start now lives in the `_handle_print_start` block at line ~3132 (right next to the existing `state.layer_num = 0`) so the previous print's total still can't bleed into the next one before its first real push arrives. **Fix B — `usage_tracker.py`.** Cascade the linear-fallback denominator: try `state.total_layers` first (the canonical source), then `last_layer_num` (the print's last-valid layer captured at completion time, already threaded into `_track_from_3mf` as a parameter for the `last_progress` partial-print case), then equal-split across segments as a last-resort fence — still wrong, but bounded. The original behaviour was strictly worse than equal-split: it always dumped 100% of the print's weight onto the last segment regardless of where the switch actually happened. **Tests.** 5 new backend cases. `test_usage_tracker.py::TestTrayChangeSplit::test_tray_switch_uses_last_layer_num_when_total_layers_reset` — the reporter's exact 260 g / 180 g split scenario with `state.total_layers=0` and `last_layer_num=260`, asserts 180.0 / 80.0 g attribution. `test_usage_tracker.py::TestTrayChangeSplit::test_tray_switch_equal_split_when_no_layer_info_at_all` — both denominators unavailable, asserts equal-split (30.0 / 30.0 g for a 60 g print) instead of the dump-to-last behaviour. `test_bambu_mqtt.py::TestTotalLayersPreservation` (× 3) — non-zero push sets the field; zero push preserves the cached value; new-print-start path explicitly resets to 0. Existing 7 `TestTrayChangeSplit` cases (including the precise-per-layer-gcode happy path and the `total_layers=100` linear fallback regression at line 1045) still green — they use a positive `total_layers` so the new cascade is dormant for them. **Scope check — what this does NOT change.** The precise per-layer 3MF branch (`extract_layer_filament_usage_from_3mf` returns data) is preferred over the linear fallback whenever per-layer data is available, so users who slice through PrintModal with full 3MF analysis stay on the precise path. Single-tray prints (no AMS Backup switch) never enter the split path at all (`len(tray_changes) > 1` gate). Path 2 (AMS remain% delta) is unaffected — it only fires for trays Path 1 didn't already attribute, and the fixed Path 1 covers the correct trays now. **One intentional semantic shift worth flagging:** `state.total_layers` now persists across the firmware-end-of-print reset frame and between prints, instead of briefly dropping to 0 at completion and staying there until the next print's first push. The explicit reset in `_handle_print_start` (line ~3135, sibling to the existing `state.layer_num = 0` reset) re-zeroes it cleanly on every new print, so the previous print's total still can't bleed into the next. Audited every consumer of `state.total_layers` across the backend (`main.py`'s first-layer notification, `metrics.py`'s Prometheus gauge, `spoolman_tracking.py`'s progress estimator, `printers.py`'s REST response, `mqtt_relay.py`'s relay payload, `printer_manager.py`'s WS payload, the finish-photo-moment trigger at `bambu_mqtt.py:2206`): none distinguishes "no active print" by `total_layers == 0` — they all check `state.state` for that. So the persistence change is invisible to every existing surface, and downstream consumers that DO read the value get a more reliable number at end-of-print and across a power-cycle. Full backend `pytest -n 30` 6222/6222 in 94 s; ruff clean. diff --git a/README.md b/README.md index aa7282c68..aaae83e2a 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R - Printer control (stop, pause, resume, chamber light, print speed, **airduct mode** for P2S/H2*, **temperature setpoints** for nozzle / bed / **chamber heater** on H2C/H2D/H2DPro/H2S/X2D, **Z-jog / XY-jog / extruder jog**, customizable temperature & fan presets under Settings → Workflow) - **Status badges on printer card**: SD Card (green / red), Enclosure Door (green / yellow — X1/P1S/P2S/H2*), Airduct Mode (cooling / heating) - **Force Refresh** menu item — request a full status push from the printer without reconnecting +- **Maintenance Mode** — put a printer "out of service" without removing it. Toggle from the card's three-dot menu, the in-card amber banner, or the Edit Printer dialog; the printer disconnects MQTT, drops out of queue dispatch, the scheduler, model-based filament lookups, metrics, and notifications until you take it out again. The card stays visible (amber wrench banner + Exit button) so the printer never disappears from your dashboard. Useful for parallel Bambuddy installs sharing the same hardware, printers under repair or awaiting parts, and temporary suspension. - Bulk printer actions (multi-select cards, then stop/pause/resume/clear all — select by state or location) - Printer search and filters — live search by name/model/location/serial plus status and location dropdown filters (WebSocket-reactive, mobile-friendly) - Resizable printer cards (S/M/L/XL) diff --git a/frontend/src/__tests__/pages/PrintersPage.test.tsx b/frontend/src/__tests__/pages/PrintersPage.test.tsx index bd332aa03..c8b0f8cb1 100644 --- a/frontend/src/__tests__/pages/PrintersPage.test.tsx +++ b/frontend/src/__tests__/pages/PrintersPage.test.tsx @@ -19,6 +19,7 @@ const mockPrinters = [ access_code: '12345678', model: 'X1C', enabled: true, + is_active: true, nozzle_diameter: 0.4, nozzle_type: 'hardened_steel', location: 'Workshop', @@ -34,6 +35,7 @@ const mockPrinters = [ access_code: '87654321', model: 'P1S', enabled: false, + is_active: true, nozzle_diameter: 0.4, nozzle_type: 'stainless_steel', location: null, @@ -538,6 +540,89 @@ describe('PrintersPage', () => { }); }); + describe('maintenance mode (#1476)', () => { + // Wraps the backend is_active flag — already gates MQTT, queue dispatch, + // scheduler, metrics, picker. These tests pin the UI surface: status + // panel swap, pill swap, and the PATCH on toggle. + const inMaintenancePrinter = { ...mockPrinters[0], is_active: false }; + + it('shows the maintenance status panel instead of the print container', async () => { + server.use( + http.get('/api/v1/printers/', () => HttpResponse.json([inMaintenancePrinter])), + http.get('/api/v1/printers/:id/status', () => + HttpResponse.json({ ...mockPrinterStatus, connected: false }), + ), + ); + render(); + + await waitFor(() => { + expect(screen.getByText('In Maintenance')).toBeInTheDocument(); + }); + // Exit button rendered + expect(screen.getByRole('button', { name: /exit maintenance/i })).toBeInTheDocument(); + // The "No active job" / "Ready to print" copy from the normal status + // panel must NOT be present — confirms the swap, not a stacked render. + expect(screen.queryByText(/no active job/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/ready to print/i)).not.toBeInTheDocument(); + }); + + it('shows the amber Maintenance pill in the header (no Connected/Offline)', async () => { + server.use( + http.get('/api/v1/printers/', () => HttpResponse.json([inMaintenancePrinter])), + http.get('/api/v1/printers/:id/status', () => + HttpResponse.json({ ...mockPrinterStatus, connected: false }), + ), + ); + render(); + + // The header pill row contains "Maintenance" exactly once. + await waitFor(() => { + expect(screen.getAllByText('Maintenance').length).toBeGreaterThan(0); + }); + // No connection diagnostic CTA (that's reserved for involuntary offline). + expect(screen.queryByRole('button', { name: /run.*diagnostic/i })).not.toBeInTheDocument(); + }); + + it('PATCHes is_active=true when the Exit button is clicked', async () => { + const patchedBodies: unknown[] = []; + server.use( + http.get('/api/v1/printers/', () => HttpResponse.json([inMaintenancePrinter])), + http.get('/api/v1/printers/:id/status', () => + HttpResponse.json({ ...mockPrinterStatus, connected: false }), + ), + http.patch('/api/v1/printers/:id', async ({ request }) => { + const body = await request.json(); + patchedBodies.push(body); + return HttpResponse.json({ ...inMaintenancePrinter, is_active: true }); + }), + ); + render(); + + const exit = await screen.findByRole('button', { name: /exit maintenance/i }); + fireEvent.click(exit); + + await waitFor(() => { + expect(patchedBodies.length).toBeGreaterThan(0); + }); + expect(patchedBodies[0]).toEqual(expect.objectContaining({ is_active: true })); + }); + + it('renders the regular status panel when is_active=true', async () => { + server.use( + http.get('/api/v1/printers/', () => HttpResponse.json([{ ...mockPrinters[0], is_active: true }])), + http.get('/api/v1/printers/:id/status', () => HttpResponse.json(mockPrinterStatus)), + ); + render(); + + await waitFor(() => { + expect(screen.getByText('X1 Carbon')).toBeInTheDocument(); + }); + // Active printer never shows the maintenance panel. + expect(screen.queryByText('In Maintenance')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /exit maintenance/i })).not.toBeInTheDocument(); + }); + }); + describe('nozzle rack card', () => { const h2cStatus = { ...mockPrinterStatus, diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index fde19d12f..6d4d5046e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -522,6 +522,10 @@ export interface PrinterCreate { model?: string; location?: string; auto_archive?: boolean; + // Maintenance Mode flag (#1476). Backend already gates MQTT, queue dispatch, + // scheduler, metrics and the print picker on this; toggling via PATCH + // /printers/{id} disconnects or reconnects MQTT accordingly. + is_active?: boolean; external_camera_url?: string | null; external_camera_type?: string | null; external_camera_enabled?: boolean; diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 33353dd2b..85391a089 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -235,6 +235,20 @@ export default { }, // Maintenance maintenanceUpToDate: 'Alle Wartungen aktuell - Klicken zum Anzeigen', + maintenance: { + title: 'In Wartung', + subtitle: 'Dieser Drucker ist pausiert — keine Verbindung, nicht für die Warteschlange verfügbar, keine Benachrichtigungen.', + pillLabel: 'Wartung', + exitButton: 'Wartung beenden', + menuEnter: 'In den Wartungsmodus wechseln', + menuExit: 'Wartungsmodus beenden', + toastEntered: '{{name}} ist jetzt im Wartungsmodus', + toastExited: '{{name}} ist wieder online', + confirmMidPrintTitle: 'Wartungsmodus während des Drucks aktivieren?', + confirmMidPrintMessage: '{{name}} druckt gerade. Der Wartungsmodus trennt die MQTT-Verbindung und beendet das Fortschritts-Tracking sowie Abschlussbenachrichtigungen für diesen Auftrag. Fortfahren?', + editFieldLabel: 'Wartungsmodus', + editFieldHelp: 'Wenn aktiviert, ist dieser Drucker von MQTT, Warteschlangenversand und Benachrichtigungen pausiert — nützlich für Reparaturen, parallele Bambuddy-Installationen oder temporäre Außerbetriebnahme.', + }, // Chamber light chamberLightOn: 'Kammerbeleuchtung einschalten', chamberLightOff: 'Kammerbeleuchtung ausschalten', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 49f8f7890..24d83724d 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -235,6 +235,23 @@ export default { }, // Maintenance maintenanceUpToDate: 'All maintenance up to date - Click to view', + // Maintenance Mode (#1476) — operator-flipped "out of service" state. + // Distinct from the scheduled-maintenance dashboard above; this one + // wraps the backend is_active flag and stops MQTT + queue dispatch. + maintenance: { + title: 'In Maintenance', + subtitle: 'This printer is paused — not connected, not eligible for the queue, not sending notifications.', + pillLabel: 'Maintenance', + exitButton: 'Exit maintenance', + menuEnter: 'Enter maintenance mode', + menuExit: 'Exit maintenance mode', + toastEntered: '{{name}} is now in maintenance mode', + toastExited: '{{name}} is back online', + confirmMidPrintTitle: 'Enter maintenance mode mid-print?', + confirmMidPrintMessage: '{{name}} is currently printing. Entering maintenance mode will disconnect MQTT and stop progress tracking and completion notifications for this job. Continue?', + editFieldLabel: 'Maintenance mode', + editFieldHelp: 'When on, this printer is paused from MQTT, queue dispatch and notifications — useful for repair, parallel Bambuddy installs, or temporary suspension.', + }, // Chamber light chamberLightOn: 'Turn on chamber light', chamberLightOff: 'Turn off chamber light', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index f60eae732..ea7d0864f 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -235,6 +235,20 @@ export default { }, // Maintenance maintenanceUpToDate: 'Todo el mantenimiento al día - Haga clic para ver', + maintenance: { + title: 'En mantenimiento', + subtitle: 'Esta impresora está en pausa — sin conexión, no elegible para la cola y sin enviar notificaciones.', + pillLabel: 'Mantenimiento', + exitButton: 'Salir de mantenimiento', + menuEnter: 'Entrar en modo mantenimiento', + menuExit: 'Salir del modo mantenimiento', + toastEntered: '{{name}} ahora está en modo mantenimiento', + toastExited: '{{name}} vuelve a estar en línea', + confirmMidPrintTitle: '¿Entrar en modo mantenimiento durante una impresión?', + confirmMidPrintMessage: '{{name}} está imprimiendo actualmente. Entrar en modo mantenimiento desconectará MQTT y detendrá el seguimiento del progreso y las notificaciones de finalización para este trabajo. ¿Continuar?', + editFieldLabel: 'Modo mantenimiento', + editFieldHelp: 'Cuando está activado, esta impresora se pausa de MQTT, despacho de cola y notificaciones — útil para reparaciones, instalaciones paralelas de Bambuddy o suspensión temporal.', + }, // Chamber light chamberLightOn: 'Encender luz de la cámara', chamberLightOff: 'Apagar luz de la cámara', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index f331fbaf1..d6bf0e9bc 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -235,6 +235,20 @@ export default { }, // Maintenance maintenanceUpToDate: 'Maintenance à jour - Cliquez pour voir', + maintenance: { + title: 'En maintenance', + subtitle: "Cette imprimante est en pause — non connectée, non éligible à la file d'attente, sans notifications.", + pillLabel: 'Maintenance', + exitButton: 'Quitter la maintenance', + menuEnter: 'Activer le mode maintenance', + menuExit: 'Quitter le mode maintenance', + toastEntered: '{{name}} est maintenant en mode maintenance', + toastExited: '{{name}} est de nouveau en ligne', + confirmMidPrintTitle: 'Passer en mode maintenance pendant une impression ?', + confirmMidPrintMessage: "{{name}} est en cours d'impression. Le mode maintenance déconnectera MQTT et arrêtera le suivi de progression ainsi que les notifications de fin pour ce travail. Continuer ?", + editFieldLabel: 'Mode maintenance', + editFieldHelp: "Quand activé, cette imprimante est mise en pause de MQTT, de la file d'attente et des notifications — utile pour les réparations, les installations Bambuddy parallèles ou une suspension temporaire.", + }, // Chamber light chamberLightOn: 'Allumer la lumière de la chambre', chamberLightOff: 'Éteindre la lumière de la chambre', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 40bcc8412..2ab71b801 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -235,6 +235,20 @@ export default { }, // Maintenance maintenanceUpToDate: 'Tutta la manutenzione aggiornata - Clicca per vedere', + maintenance: { + title: 'In manutenzione', + subtitle: 'Questa stampante è in pausa — non connessa, non idonea per la coda, nessuna notifica inviata.', + pillLabel: 'Manutenzione', + exitButton: 'Esci dalla manutenzione', + menuEnter: 'Entra in modalità manutenzione', + menuExit: 'Esci dalla modalità manutenzione', + toastEntered: '{{name}} è ora in modalità manutenzione', + toastExited: '{{name}} è di nuovo online', + confirmMidPrintTitle: 'Entrare in modalità manutenzione durante la stampa?', + confirmMidPrintMessage: '{{name}} sta attualmente stampando. Entrare in modalità manutenzione disconnetterà MQTT e fermerà il tracciamento del progresso e le notifiche di completamento per questo lavoro. Continuare?', + editFieldLabel: 'Modalità manutenzione', + editFieldHelp: 'Quando attivata, questa stampante è in pausa da MQTT, dispatch della coda e notifiche — utile per riparazioni, installazioni Bambuddy parallele o sospensione temporanea.', + }, // Chamber light chamberLightOn: 'Accendi luce camera', chamberLightOff: 'Spegni luce camera', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 4830d30fd..f489d7e13 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -234,6 +234,20 @@ export default { }, // Maintenance maintenanceUpToDate: 'すべてのメンテナンスが最新です', + maintenance: { + title: 'メンテナンス中', + subtitle: 'このプリンターは一時停止中です — 未接続、キュー対象外、通知も送信されません。', + pillLabel: 'メンテナンス', + exitButton: 'メンテナンスを終了', + menuEnter: 'メンテナンスモードに入る', + menuExit: 'メンテナンスモードを終了', + toastEntered: '{{name}} はメンテナンスモードになりました', + toastExited: '{{name}} がオンラインに戻りました', + confirmMidPrintTitle: '印刷中にメンテナンスモードに入りますか?', + confirmMidPrintMessage: '{{name}} は現在印刷中です。メンテナンスモードに入るとMQTTが切断され、このジョブの進行状況の追跡と完了通知が停止します。続行しますか?', + editFieldLabel: 'メンテナンスモード', + editFieldHelp: '有効にすると、このプリンターはMQTT、キューディスパッチ、通知から一時停止されます — 修理、並列のBambuddyインストール、または一時的な停止に役立ちます。', + }, // Chamber light chamberLightOn: 'チャンバーライトをオンにしました', chamberLightOff: 'チャンバーライトをオフにしました', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index e9fecaf2f..bd0c2ab79 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -218,6 +218,20 @@ export default { excellent: '매우 좋음' }, maintenanceUpToDate: '모든 유지보수 최신 상태 - 클릭하여 보기', + maintenance: { + title: '유지보수 중', + subtitle: '이 프린터는 일시 중지되었습니다 — 연결되지 않음, 대기열에서 제외, 알림 전송 안 함.', + pillLabel: '유지보수', + exitButton: '유지보수 종료', + menuEnter: '유지보수 모드로 전환', + menuExit: '유지보수 모드 종료', + toastEntered: '{{name}} 은(는) 이제 유지보수 모드입니다', + toastExited: '{{name}} 이(가) 다시 온라인 상태입니다', + confirmMidPrintTitle: '인쇄 중 유지보수 모드로 전환하시겠습니까?', + confirmMidPrintMessage: '{{name}} 은(는) 현재 인쇄 중입니다. 유지보수 모드로 전환하면 MQTT가 연결 해제되고 이 작업의 진행률 추적 및 완료 알림이 중지됩니다. 계속하시겠습니까?', + editFieldLabel: '유지보수 모드', + editFieldHelp: '활성화하면 이 프린터는 MQTT, 대기열 디스패치 및 알림에서 일시 중지됩니다 — 수리, 병렬 Bambuddy 설치 또는 임시 중지에 유용합니다.', + }, chamberLightOn: '챔버 조명 켜기', chamberLightOff: '챔버 조명 끄기', files: '파일', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index acb06047a..6885b59fc 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -235,6 +235,20 @@ export default { }, // Maintenance maintenanceUpToDate: 'Toda a manutenção está em dia - Clique para ver', + maintenance: { + title: 'Em manutenção', + subtitle: 'Esta impressora está pausada — sem conexão, não elegível para a fila, sem enviar notificações.', + pillLabel: 'Manutenção', + exitButton: 'Sair da manutenção', + menuEnter: 'Entrar no modo manutenção', + menuExit: 'Sair do modo manutenção', + toastEntered: '{{name}} está agora em modo manutenção', + toastExited: '{{name}} está online novamente', + confirmMidPrintTitle: 'Entrar em modo manutenção durante uma impressão?', + confirmMidPrintMessage: '{{name}} está imprimindo no momento. Entrar em modo manutenção desconectará o MQTT e interromperá o acompanhamento do progresso e as notificações de conclusão para este trabalho. Continuar?', + editFieldLabel: 'Modo manutenção', + editFieldHelp: 'Quando ativado, esta impressora é pausada do MQTT, despacho de fila e notificações — útil para reparos, instalações paralelas do Bambuddy ou suspensão temporária.', + }, // Chamber light chamberLightOn: 'Ligar luz da câmara', chamberLightOff: 'Desligar luz da câmara', diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index 57d57ef8d..1cca68a8c 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/frontend/src/i18n/locales/tr.ts @@ -235,6 +235,20 @@ export default { }, // Bakım maintenanceUpToDate: 'Tüm bakım güncel - Görüntülemek için tıklayın', + maintenance: { + title: 'Bakımda', + subtitle: 'Bu yazıcı duraklatıldı — bağlı değil, kuyruğa uygun değil, bildirim göndermiyor.', + pillLabel: 'Bakım', + exitButton: 'Bakımdan çık', + menuEnter: 'Bakım moduna gir', + menuExit: 'Bakım modundan çık', + toastEntered: '{{name}} artık bakım modunda', + toastExited: '{{name}} tekrar çevrimiçi', + confirmMidPrintTitle: 'Yazdırma sırasında bakım moduna geçilsin mi?', + confirmMidPrintMessage: '{{name}} şu anda yazdırıyor. Bakım moduna geçmek MQTT bağlantısını kesecek ve bu iş için ilerleme takibi ile tamamlanma bildirimlerini durduracak. Devam edilsin mi?', + editFieldLabel: 'Bakım modu', + editFieldHelp: 'Etkinleştirildiğinde, bu yazıcı MQTT, kuyruk gönderimi ve bildirimlerden duraklatılır — tamir, paralel Bambuddy kurulumları veya geçici askıya alma için kullanışlıdır.', + }, // Hazne ışığı chamberLightOn: 'Hazne ışığını aç', chamberLightOff: 'Hazne ışığını kapat', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 351b3b958..f5e46e4a3 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -235,6 +235,20 @@ export default { }, // Maintenance maintenanceUpToDate: '所有维护均已完成 - 点击查看', + maintenance: { + title: '维护中', + subtitle: '此打印机已暂停 — 未连接、不参与队列调度、不发送通知。', + pillLabel: '维护', + exitButton: '退出维护', + menuEnter: '进入维护模式', + menuExit: '退出维护模式', + toastEntered: '{{name}} 已进入维护模式', + toastExited: '{{name}} 已恢复在线', + confirmMidPrintTitle: '在打印过程中进入维护模式?', + confirmMidPrintMessage: '{{name}} 当前正在打印。进入维护模式将断开 MQTT 连接,并停止此任务的进度跟踪和完成通知。是否继续?', + editFieldLabel: '维护模式', + editFieldHelp: '启用后,此打印机将从 MQTT、队列调度和通知中暂停 — 适用于维修、并行 Bambuddy 安装或临时停用。', + }, // Chamber light chamberLightOn: '打开腔室灯', chamberLightOff: '关闭腔室灯', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index 5a69f7c51..36778666f 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -235,6 +235,20 @@ export default { }, // Maintenance maintenanceUpToDate: '所有維護均已完成 - 點選檢視', + maintenance: { + title: '維護中', + subtitle: '此印表機已暫停 — 未連接、不參與佇列調度、不發送通知。', + pillLabel: '維護', + exitButton: '退出維護', + menuEnter: '進入維護模式', + menuExit: '退出維護模式', + toastEntered: '{{name}} 已進入維護模式', + toastExited: '{{name}} 已恢復在線', + confirmMidPrintTitle: '在列印過程中進入維護模式?', + confirmMidPrintMessage: '{{name}} 目前正在列印。進入維護模式將斷開 MQTT 連線,並停止此工作的進度追蹤和完成通知。是否繼續?', + editFieldLabel: '維護模式', + editFieldHelp: '啟用後,此印表機將從 MQTT、佇列調度和通知中暫停 — 適用於維修、並行 Bambuddy 安裝或暫時停用。', + }, // Chamber light chamberLightOn: '開啟腔室燈', chamberLightOff: '關閉腔室燈', diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index ff3645303..4f04c6eaa 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -2507,6 +2507,38 @@ function PrinterCard({ onError: (error: Error) => showToast(error.message || t('printers.toast.failedToUpdateSetting'), 'error'), }); + // Maintenance mode toggle (#1476). Wraps the `is_active` backend field that + // already gates MQTT connection, queue dispatch, scheduler eligibility, + // metrics, and the print picker — so flipping this flag puts the printer + // out of service across every consumer in one place. Used from the + // overflow menu and EditPrinterModal. + const maintenanceMutation = useMutation({ + mutationFn: (isActive: boolean) => api.updatePrinter(printer.id, { is_active: isActive }), + onSuccess: (_data, isActive) => { + queryClient.invalidateQueries({ queryKey: ['printers'] }); + queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] }); + showToast( + isActive + ? t('printers.maintenance.toastExited', { name: printer.name }) + : t('printers.maintenance.toastEntered', { name: printer.name }), + 'success', + ); + }, + onError: (error: Error) => showToast(error.message || t('printers.toast.failedToUpdateSetting'), 'error'), + }); + + // Confirm before entering maintenance on a printing printer (entering mode + // disconnects MQTT, which stops progress tracking + completion notifications + // for the in-flight job). + const [confirmMaintenanceEnter, setConfirmMaintenanceEnter] = useState(false); + const handleEnterMaintenance = () => { + if (status?.state === 'RUNNING' || status?.state === 'PAUSE') { + setConfirmMaintenanceEnter(true); + } else { + maintenanceMutation.mutate(false); + } + }; + // Query for printable objects (for skip functionality) // Fetch when printing with 2+ objects OR when modal is open const isPrintingWithObjects = (status?.state === 'RUNNING' || status?.state === 'PAUSE') && (status?.printable_objects_count ?? 0) >= 2; @@ -2996,6 +3028,30 @@ function PrinterCard({ {t('printers.printerInformation')} + {/* Maintenance Mode toggle (#1476) — leverages backend is_active flag */} + + + + )} + + ) : status?.connected && ( <> {/* Compact: Simple status bar */} {viewMode === 'compact' ? ( @@ -5852,6 +5968,24 @@ function PrinterCard({ /> )} + {/* Maintenance Mode mid-print confirmation (#1476) — entering maintenance + disconnects MQTT, which stops progress tracking + completion + notifications for the in-flight job. Idle / FINISH / FAILED states + skip this dialog and toggle directly. */} + {confirmMaintenanceEnter && ( + { + maintenanceMutation.mutate(false); + setConfirmMaintenanceEnter(false); + }} + onCancel={() => setConfirmMaintenanceEnter(false)} + /> + )} + {/* Power Off Confirmation */} {showPowerOffConfirm && smartPlug && ( + {/* Maintenance Mode toggle (#1476) — checkbox is the inverse of + is_active because the user-facing concept is "is this printer + in maintenance" not "is it active". */} +
+
+ setForm({ ...form, is_active: !e.target.checked })} + className="rounded border-bambu-dark-tertiary bg-bambu-dark text-amber-400 focus:ring-amber-400" + /> + +
+

+ {t('printers.maintenance.editFieldHelp')} +

+
{saveWarning ? (