diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ba12149d..8db3ddb50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ All notable changes to Bambuddy will be documented in this file. - **Copy spool — duplicate any spool's settings into a fresh inventory row in two clicks** ([#1234](https://github.com/maziggy/bambuddy/issues/1234), [PR #1246](https://github.com/maziggy/bambuddy/pull/1246) by @MiguelAngelLV) — Adds a copy button (`Copy` icon) next to the existing edit button on every spool in the inventory page across all three views (table row, card, grouped table inner row). Clicking it opens the existing `SpoolFormModal` pre-filled with every field from the source spool — material, brand, color, slicer preset, label/core/cost, K-profiles, all of it — except `weight_used` which is reset to 0 (since the new spool starts full) and the RFID identity fields (`tag_uid`, `tray_uuid`, `tag_type`, `data_origin`) which aren't part of the form payload anyway, so the new spool is its own physical roll. Save calls `api.createSpool` (or `api.createSpoolmanInventorySpool` in Spoolman mode — both inherit the dispatch routing for free). Closes the long-running gap where users with many near-identical spools (e.g. five 1 kg PETG-CF rolls bought in a single order) had to re-enter every field from scratch on each one. **Implementation shape:** `SpoolFormModalProps.mode: 'create' | 'edit' | 'copy'` (exported as `SpoolFormMode`) replaces the previous `isEditing = !!spool` heuristic — every existing call site in `InventoryPage.tsx` was updated to pass the explicit mode, and the modal's title / submit-button label / weight-reset gate / submit-route branching all key on `mode` directly. The `onCopy` callback is optional on `SpoolCard`, `SpoolTableRow`, and `SpoolTableGroup` (matches the existing `onPrintLabel?` pattern), so the button is conditionally rendered and other consumers of those subcomponents don't get a copy affordance forced on them. Card-view and table-row buttons stop click propagation so clicking copy doesn't also fire the parent row's edit handler. **Quick Add interaction:** the Quick Add toggle is gated `mode === 'create'` (was `!isEditing`), so it stays out of copy mode — otherwise a user could enable Quick Add and bump quantity to N under the singular "Copy Spool" title and silently bulk-create N copies via `bulkCreateMutation`. **i18n:** new `inventory.copySpool` key across all 8 locales (en + de translated, fr/it/ja/pt-BR/zh-CN/zh-TW seeded with English fallback per project flow). **Tests:** 3 new in `SpoolFormModal.test.tsx` (`SpoolFormModal copy mode` describe block — title shows "Copy Spool", save calls `createSpool` not `updateSpool`, `weight_used` reset to 0 in the create payload when copying a spool with non-zero usage), 2 new in `InventoryPageCopyButton.test.tsx` (table-row copy button click → "Copy Spool" heading, cards-view copy button click → same heading after switching view modes) — guards against the three call sites drifting apart. Existing `SpoolFormBulk.test.tsx` and `SpoolFormModal.test.tsx` renders that omitted the `mode` prop were updated with the explicit `mode="create"` so the tightened Quick Add gate doesn't hide the toggle from them. Both `InventoryPageCopyButton.test.tsx` and `InventoryPageDeepLink.test.tsx` gained MSW handlers for the modal's open-time fetches (`/api/v1/cloud/status`, `/api/v1/cloud/local-presets`, `/api/v1/cloud/builtin-filaments`, `/api/v1/inventory/color-catalog`, `/api/v1/inventory/spool-catalog`, `/api/v1/printers/`) — without them MSW passes through to the real network, ECONNREFUSEs, and the rejected fetch resolves after the test environment is torn down, surfacing as a flaky "window is not defined" unhandled rejection in the modal's `setLoadingCloudPresets(false)` finally block (pre-existing flake hit ~1 in 3 full-suite runs at PR head). ### Fixed +- **Printer card's "Show on Printer Card" smart-plug button toggled power without confirmation** ([#1260](https://github.com/maziggy/bambuddy/issues/1260), reported by @thkl) — Smart plugs with the "Show on Printer Card" option enabled appear as a clickable chip in the printer card's HA-entities row (below the main Smart Plug controls). One click cut power to the printer instantly — including mid-print — even though the main Off button next to it already routes through a `ConfirmModal` and shows an additional running-print warning. **Fix:** the HA-row click handler in `frontend/src/pages/PrintersPage.tsx` now branches on entity type — `script.*` entities keep firing instantly (a script is a fire-once trigger, not a power switch, and the existing semantic of "Run" matches user expectation), but switch/light/anything-else entities now open a new `ConfirmModal` first. The modal reuses the same `variant="danger"` + running-print warning shape as the existing power-off confirmation: when `status?.state === 'RUNNING'` it shows the "WARNING: is currently printing! Toggling may cut power and interrupt the print" copy, and renders the default-variant "Toggle the Home Assistant entity ?" message otherwise. The entity name comes from `ha_entity_id` (with `name` fallback) so the modal disambiguates which of multiple plugs the click was on. **i18n:** new `printers.confirm.{haToggleTitle, haToggleMessage, haToggleWarning, haToggleButton}` keys added across all 8 locales (en + de + fr + it + ja + pt-BR + zh-CN + zh-TW translated to native, no English-fallback seeding). Full PrintersPage frontend suite (49 tests) still passes; build clean. + - **X2D / H2D dual-nozzle without AMS: filament mapping reported "Required filament type not found in printer" even when the spools were physically loaded** ([#1257](https://github.com/maziggy/bambuddy/issues/1257)) — Repro: X2D with 0 AMS units, two external spools (Ext-L feeding left extruder, Ext-R feeding right), print job specifies `nozzle_id` per filament. The Schedule Print modal showed the orange "Filament Mapping (Type not found)" header and a forced manual slot picker, even though the matching PETG was sitting right there in the external spool holder. **Cause:** `frontend/src/hooks/useFilamentMapping.ts:18-19` derived dual-nozzle status solely from `printerStatus.ams_extruder_map` being non-empty. That map is populated from AMS units' info bits, so a dual-nozzle printer with zero AMS units gets an empty map → `hasDualNozzle = false` → external spools' `extruderId` falls through to `undefined` (line 64 ternary fallback). The downstream nozzle-aware filter at lines 117 / 377 (`available.filter((f) => f.extruderId === req.nozzle_id)`) then rejected every loaded filament because `undefined !== 0/1` for any non-null `nozzle_id`. The PETG was loaded, just incorrectly stripped from the candidate set during matching. **Fix:** widen the dual-nozzle inference to three independent signals OR'd together: (1) `nozzles[1].nozzle_diameter` populated — the most direct signal, set by `bambu_mqtt.py:2619-2621` only when the printer reports a `right_nozzle_diameter` MQTT field, so a populated value always implies real second-nozzle hardware; (2) `ams_extruder_map` non-empty — preserved as fallback for the dual-nozzle-with-AMS case the original code already handled; (3) `vt_tray.length > 1` — single-nozzle printers (P1S / A1 / X1C) only have one external feed, so multiple external trays only exist on dual-nozzle hardware. The first signal alone is *not* sufficient because the backend `state.nozzles` defaults to a 2-entry list with empty `NozzleInfo()` stubs (`bambu_mqtt.py:160`) on every printer, single-nozzle included — `nozzles.length` would always be 2 on the wire and would have regressed every single-nozzle install. Affects all dual-nozzle printers running without AMS: X2D, H2D, X2 Pro. **Tests:** two new regressions in `src/__tests__/hooks/useFilamentMapping.test.ts`. `matches external spools per-extruder on dual-nozzle without AMS` pins the bug fix — asserts each external spool gets the correct `extruderId` (1 for Ext-L id=254, 0 for Ext-R id=255) and `computeAmsMapping` picks Ext-L for a left-nozzle requirement. `does not fabricate extruderId for single-nozzle with stub nozzles[1]` is the matching guard — asserts that a P1S / A1 / X1C-shape PrinterStatus (with the default-stub second nozzle entry the backend always emits) does NOT trip the dual-nozzle inference, so single-nozzle external spools keep `extruderId=undefined` exactly as they did pre-fix. Together they pin both directions: a future change that re-breaks the X2D path fails CI, and one that mistakenly turns single-nozzle printers into dual-nozzle also fails CI. Full frontend suite (1891 tests across 138 files) green. - **GCode Viewer had no in-app way to navigate back — the only exit was the browser's back button** — Opening the GCode Viewer from a File Manager card or an Archive card calls `navigate('/gcode-viewer?archive=…' | '?library_file=…')`, which mounts `GCodeViewerPage` as a full-height iframe inside the Layout shell. The page rendered nothing but the iframe, so once the third-party viewer's UI took over the content area there was no in-app affordance to return to the originating list — only the browser's back button. Reported by @maziggy. **Fix:** added a thin back bar above the iframe in `frontend/src/pages/GCodeViewerPage.tsx` with an `ArrowLeft` icon button. The button label adapts to the entry point — `Back to Print Archives` when the URL carries `?archive=`, `Back to File Manager` when it carries `?library_file=`, generic `Back` otherwise (covers the rare deep-link / shared-URL case). Click prefers `navigate(-1)` so the user lands back in their original list with scroll position and filters preserved; falls back to `/archives` or `/files` when the page was opened in a fresh tab and there's no SPA history to return to. Iframe height is now `flex: 1` inside a flex column under the bar instead of a hard-coded `calc(100vh - 3.5rem)` — the layout's existing fixed-header offset is unchanged, only the back bar (~36 px) is subtracted from the viewer's vertical real estate. **i18n:** new `gcodeViewer.{back,backToArchives,backToFiles}` namespace added to all 8 locales (en + de fully translated, fr/it/ja/pt-BR/zh-CN/zh-TW translated to native using each locale's existing page-title vocabulary — `Druckarchiv`/`Dateimanager`, `Archives d'impression`/`Gestionnaire de fichiers`, `Archivi di stampa`/`Gestore file`, `印刷アーカイブ`/`ファイル管理`, `Arquivos de impressão`/`Gerenciador de arquivos`, `打印归档`/`文件管理器`, `列印歸檔`/`檔案管理器`). diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 79eb61f12..8f21aaac2 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -429,6 +429,10 @@ export default { powerOffMessage: 'Möchten Sie die Stromversorgung für "{{name}}" wirklich AUSSCHALTEN?', powerOffWarning: 'WARNUNG: "{{name}}" druckt gerade! Möchten Sie die Stromversorgung wirklich AUSSCHALTEN? Dies unterbricht den Druck und kann den Drucker beschädigen.', powerOffButton: 'Ausschalten', + haToggleTitle: '"{{name}}" umschalten', + haToggleMessage: 'Home-Assistant-Entität {{entity}} umschalten? Das kann die Stromversorgung ausschalten, falls sie gerade an ist.', + haToggleWarning: 'WARNUNG: "{{name}}" druckt gerade! Umschalten von {{entity}} kann die Stromversorgung trennen und den Druck abbrechen. Fortfahren?', + haToggleButton: 'Umschalten', }, // Bulk actions bulk: { diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index f52b73263..84597e00b 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -429,6 +429,10 @@ export default { powerOffMessage: 'Are you sure you want to turn OFF the power for "{{name}}"?', powerOffWarning: 'WARNING: "{{name}}" is currently printing! Are you sure you want to turn OFF the power? This will interrupt the print and may damage the printer.', powerOffButton: 'Power Off', + haToggleTitle: 'Toggle "{{name}}"', + haToggleMessage: 'Toggle the Home Assistant entity {{entity}}? This may turn power off if it is currently on.', + haToggleWarning: 'WARNING: "{{name}}" is currently printing! Toggling {{entity}} may cut power and interrupt the print. Continue?', + haToggleButton: 'Toggle', }, // Bulk actions bulk: { diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 87e29051e..b2fee5235 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -429,6 +429,10 @@ export default { powerOffMessage: 'Éteindre "{{name}}" ?', powerOffWarning: 'ATTENTION : "{{name}}" imprime ! L\'éteindre maintenant peut endommager l\'imprimante.', powerOffButton: 'Éteindre', + haToggleTitle: 'Basculer "{{name}}"', + haToggleMessage: 'Basculer l\'entité Home Assistant {{entity}} ? Cela peut couper l\'alimentation si elle est actuellement activée.', + haToggleWarning: 'ATTENTION : "{{name}}" imprime ! Basculer {{entity}} peut couper l\'alimentation et interrompre l\'impression. Continuer ?', + haToggleButton: 'Basculer', }, // Bulk actions bulk: { diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 88cd5b01f..488702285 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -429,6 +429,10 @@ export default { powerOffMessage: 'Sei sicuro di spegnere "{{name}}"?', powerOffWarning: 'AVVISO: "{{name}}" sta stampando! Sei sicuro di spegnere? Questo interromperà la stampa e potrebbe danneggiare la stampante.', powerOffButton: 'Spegni', + haToggleTitle: 'Commuta "{{name}}"', + haToggleMessage: 'Commutare l\'entità Home Assistant {{entity}}? Questo potrebbe spegnere l\'alimentazione se attualmente è accesa.', + haToggleWarning: 'AVVISO: "{{name}}" sta stampando! La commutazione di {{entity}} potrebbe togliere l\'alimentazione e interrompere la stampa. Continuare?', + haToggleButton: 'Commuta', }, // Bulk actions bulk: { diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 18588642a..58bd813a8 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -428,6 +428,10 @@ export default { powerOffMessage: '「{{name}}」の電源をオフにしますか?', powerOffWarning: '警告: 「{{name}}」は現在印刷中です!電源をオフにしますか?印刷が中断され、プリンターが損傷する可能性があります。', powerOffButton: '電源オフ', + haToggleTitle: '「{{name}}」を切り替え', + haToggleMessage: 'Home Assistant エンティティ {{entity}} を切り替えますか?現在オンの場合、電源がオフになる可能性があります。', + haToggleWarning: '警告: 「{{name}}」は現在印刷中です!{{entity}} を切り替えると電源が切れ、印刷が中断される可能性があります。続行しますか?', + haToggleButton: '切り替え', }, // Bulk actions bulk: { diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index cdbf8a2c2..db1c9803b 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -429,6 +429,10 @@ export default { powerOffMessage: 'Tem certeza de que deseja desligar a impressora "{{name}}"?', powerOffWarning: 'AVISO: "{{name}}" está imprimindo no momento! Tem certeza de que deseja desligar a impressora? Isso interromperá a impressão e pode danificar a impressora.', powerOffButton: 'Desligar', + haToggleTitle: 'Alternar "{{name}}"', + haToggleMessage: 'Alternar a entidade Home Assistant {{entity}}? Isso pode desligar a energia se estiver ligada no momento.', + haToggleWarning: 'AVISO: "{{name}}" está imprimindo no momento! Alternar {{entity}} pode cortar a energia e interromper a impressão. Continuar?', + haToggleButton: 'Alternar', }, // Bulk actions bulk: { diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 042143d7a..f6677d814 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -429,6 +429,10 @@ export default { powerOffMessage: '确定要关闭"{{name}}"的电源吗?', powerOffWarning: '警告:"{{name}}"正在打印中!确定要关闭电源吗?这将中断打印并可能损坏打印机。', powerOffButton: '关机', + haToggleTitle: '切换"{{name}}"', + haToggleMessage: '切换 Home Assistant 实体 {{entity}}?如果当前为开启状态,可能会关闭电源。', + haToggleWarning: '警告:"{{name}}"正在打印中!切换 {{entity}} 可能会切断电源并中断打印。是否继续?', + haToggleButton: '切换', }, // Bulk actions bulk: { diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index a1b623b48..0a81beb5e 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -429,6 +429,10 @@ export default { powerOffMessage: '確定要關閉"{{name}}"的電源嗎?', powerOffWarning: '警告:"{{name}}"正在列印中!確定要關閉電源嗎?這將中斷列印並可能損壞印表機。', powerOffButton: '關機', + haToggleTitle: '切換"{{name}}"', + haToggleMessage: '切換 Home Assistant 實體 {{entity}}?如果目前為開啟狀態,可能會關閉電源。', + haToggleWarning: '警告:"{{name}}"正在列印中!切換 {{entity}} 可能會切斷電源並中斷列印。是否繼續?', + haToggleButton: '切換', }, // Bulk actions bulk: { diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index 2391a013c..6d22a1ac2 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -65,7 +65,7 @@ import { import { useNavigate } from 'react-router-dom'; import { api, discoveryApi, firmwareApi, withStreamToken } from '../api/client'; import { formatDateOnly, formatETA, formatDuration, parseUTCDate } from '../utils/date'; -import type { Printer, PrinterCreate, PrinterStatus, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo, SpoolAssignment, HMSError, InventorySpool } from '../api/client'; +import type { Printer, PrinterCreate, PrinterStatus, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo, SpoolAssignment, HMSError, InventorySpool, SmartPlug } from '../api/client'; import { Card, CardContent } from '../components/Card'; import { Button } from '../components/Button'; import { ConfirmModal } from '../components/ConfirmModal'; @@ -1470,6 +1470,7 @@ function PrinterCard({ const [showMQTTDebug, setShowMQTTDebug] = useState(false); const [showPowerOnConfirm, setShowPowerOnConfirm] = useState(false); const [showPowerOffConfirm, setShowPowerOffConfirm] = useState(false); + const [haToggleConfirm, setHaToggleConfirm] = useState(null); const [showHMSModal, setShowHMSModal] = useState(false); const [showStopConfirm, setShowStopConfirm] = useState(false); const [showPauseConfirm, setShowPauseConfirm] = useState(false); @@ -4645,7 +4646,13 @@ function PrinterCard({ return (