diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a7b4aeba..c3d6ffb8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to Bambuddy will be documented in this file. -## [1.2.5.7] - 2026-10-02 +## [1.2.6b1] - Unreleased ### Added - **A Column view in the File Manager (#3020, requested and contributed by @Thomansky in #3190)** — Next to Grid and List there is now **Column view**, as in the macOS Finder: one column per folder level, each listing that level's subfolders and then its own files, with the selected folder's files in the pane on the right. It shows where you are in the folder tree and lets you walk up and down it without going back to the sidebar. File rows carry the same actions as the list view (Print, Slice, Preview, Download, Details, Rename, Delete and the rest), and folder rows the same ⋮ menu as the sidebar. It works from the keyboard too: ↑/↓ move within a column, → or Enter go down a level, ← goes up, Enter opens a file's preview, Space ticks it, and the context-menu key or Shift+F10 moves into the row's actions. A file ticked in any column counts for the toolbar's Print, Preview, Combine, Move and Delete. With a search or tag filter active the columns step aside and the results take the full width, since they span every folder. The choice is remembered like Grid and List. Changing folders now clears the selection in every view, so Move and Delete can't act on files that are no longer on screen. The sidebar's folder ⋮ menu now closes when you click it again. @@ -11,23 +11,54 @@ All notable changes to Bambuddy will be documented in this file. - **Combine several STLs, or several copies of one, onto one plate (#2999, requested by @Markus98, contributed by @adman234 in #3162)** — The slicer sidecar slices one model at a time, so putting separate STLs on one plate needed a desktop slicer to build the 3MF first. Select one or more STLs in the File Manager and click **Combine to 3MF**, set how many copies of each you want, and Bambuddy saves a new 3MF with every object on one plate, with a preview image. Tick **Open the slicer when done** to go straight to the Slice dialog with auto-arrange already on. The source STLs are left untouched. A plate holds at most 100 objects, and the selected STLs can be at most 300 MB and 5 million triangles in total; each STL is stored once however many copies you place. - **The API-key printer status carries layers, the job id, HMS faults and the serial (#2919, requested by @simplytoast1)** — `GET /api/v1/webhook/printer/{id}/status`, the compact status route polled by add-ons such as notify-bambuddy for iOS Live Activities, returned only state, progress and time remaining. It now also returns `layer_num` and `total_layers`, the printer's `subtask_id` for the running job (a new value means a new print, even between two polls; `null` when Bambu gives the job no id), the live `hms_errors` in the same shape as the printer status route (so a filament runout reads differently from a manual pause), and the printer's `serial_number`. `remaining_time` stays in minutes for existing clients; the new `remaining_seconds` gives the same estimate in seconds, the unit notifications use. Nothing existing changed, and the route still needs **Read Status** and honours a key's printer limits. - **Choose what goes on a spool label, see it before printing, and save labels as PNG (#2981, requested by @apizz)** — Spool labels always carried the same lines, and you only saw the result once the PDF opened. The label picker now has a checkbox for each line: brand, material and subtype, colour code, name, storage location, material number, nozzle temperature, net weight, note, date added, QR code and spool ID. Material number, temperature, weight, note and date were never printed before, and leaving out the QR code gives the text its space. A preview beside the options shows the first selected spool's label as it will print, and updates as you change them. Lines that don't fit on the chosen size are left out rather than printed over the spool ID. Labels can now also be saved as PNG at 203, 300 or 600 dpi for label printer software that takes images, such as Brother P-touch Editor: one label comes as a PNG, several as a ZIP. The picker remembers the size, the lines chosen for each size, monochrome and the output settings in your browser. Spoolman labels now carry the same data as built-in ones, including the subtype and the colour name you set, where before they showed neither. +- **The outcome prompt can be answered by reacting to the Telegram message (#3046, requested and contributed by @Thomansky in #3129)** — The Good/Reject buttons in a Telegram outcome prompt open a Bambuddy link, which only works when the phone can reach Bambuddy. A thumbs-up or thumbs-down on the message now records the verdict instead: the phone only talks to Telegram, so it works away from home, behind NAT or with the VPN down, and needs no external URL. A Telegram provider gains **Outcome verdict via**: **Inline buttons (link)** (the default, unchanged), **Reaction (👍 / 👎)**, or **Buttons and reaction**. Bambuddy then polls the bot for reactions (one poll per bot, outbound only) and edits the message to say "marked as good" or "marked as reject". The first verdict wins, whichever way it arrives, and a reaction stops counting exactly when the prompt's links would, so a message from an earlier run can't answer a reprint. Use a bot only Bambuddy uses for reactions: another app polling the same bot, such as Home Assistant's Telegram integration, stops receiving its messages. In a group the bot must be an admin to see reactions. The Archives page's **Unconfirmed** filter now lists completed prints only, like the **outcome?** badge. - **SSO logins can keep a user's Bambuddy groups in step with their groups at the identity provider (#3107, requested and contributed by @willuhmjs in #3122)** — Each OIDC provider in Settings → Authentication → SSO/OIDC gains a **Group Claim** (default `groups`) and a **Group Mapping**, a list of rows pairing an identity provider group with a Bambuddy group. On every SSO login, existing accounts included, the mapped Bambuddy groups follow the provider: joining a provider group grants its Bambuddy group, leaving it removes that group at the next login. Groups the mapping doesn't name are left alone, so a manual promotion sticks. This is the same rule the LDAP group mapping uses. Groups are read from the signed ID token, as a list or as a space- or comma-separated string, and matched ignoring case. Claims with a namespace such as `app/roles` work, for Auth0. A missing claim, or a sync that fails, never blocks the login; the user keeps the groups they had. The form offers only existing Bambuddy groups, flags a row pointing at a deleted group, and won't save a half-filled row or a second row for the same provider group. The provider card shows whether group sync is on. With no mapping, a provider behaves exactly as before. Docker and env-var setups use `BAMBUDDY_OIDC_GROUP_CLAIM` and `BAMBUDDY_OIDC_GROUP_MAPPING`, a JSON object whose values are Bambuddy group names. A mapping naming a group that doesn't exist, or one that isn't valid, is refused at startup with the reason in the log, and the app still starts. - **Spools can carry your own material number, and statistics group by it (#2870, requested and contributed by @Thomansky in #2994)** — A new **Material No.** field in the spool dialog's Additional section holds the internal number you buy and cost a product by, such as `15` for Bambu Lab PLA Basic. Several spools of the same product share it. The field suggests numbers already in use, and surrounding spaces are trimmed, so `15` and `15 ` stay one number. A new spool of a product that already has a number gets it automatically: same brand, material, subtype and colour, whether it is added by hand, in bulk, through the API or by the RFID auto-add. CSV import is the exception: the file decides. The inventory has an optional sortable **Material No.** column, which sorts `2` before `15`, and a filter for one number or for spools without one. Search finds the number, and Bulk Edit can assign it to many spools at once. The Statistics page has a **By Material Number** widget: active spools and remaining stock per number, plus consumption and cost from the usage history, archived spools included, following the dashboard's date range. The number round-trips through CSV export/import and is part of the GitHub backup. The GitHub backup now also keeps a spool's category, low-stock threshold and storage location; a restore dropped them before. In Spoolman mode the number is Spoolman's own article number, shown read-only and edited in Spoolman. Bulk Edit there no longer offers category and low-stock threshold, which Spoolman cannot store, so setting them never took effect. - **Suppliers are a managed list, and each spool can record where it can be bought and where it was bought (#2988, requested and contributed by @Thomansky in #2996)** — A **Suppliers** button next to Locations on the Inventory toolbar opens the supplier list, in both inventory modes. In the spool dialog you assign any number of suppliers, each with its own article number and a quoted price per kg for comparing sources, and mark at most one as *bought here*. The quoted price is only for comparison; it never changes the spool's cost. You can add a supplier from the dialog without leaving it. A supplier still assigned to spools cannot be deleted. The inventory has an optional sortable **Suppliers** column, a supplier filter, and search matches supplier names and article numbers. A new spool of a product that already has suppliers gets the same list, but never the *bought here* marker. The Statistics page has a **By Supplier** widget: consumption and cost grouped by where each spool was bought, following the dashboard's date range. CSV export has `suppliers` and `purchase_supplier` columns. Import matches the names against your list and never creates suppliers: an unknown name is reported in the preview and that assignment is skipped. In Spoolman mode the assignments are stored in Bambuddy, next to the Spoolman spool. +- **Library files can carry an external link, notes and photos (#3077, requested by @SergioFuchs, contributed by @Thomansky in #3128)** — The File Manager now gives library files what archives already have. **File details** in a file's card menu, or the info icon on a list row, opens a window with the file's facts (size, print details, the MakerWorld source if there is one), an editable notes field, a link, and a photo gallery. Photos can be JPG, PNG or WebP, up to 10 MB each, and open full size when clicked. Only http and https links are accepted. The grid shows a globe icon that opens the link, a notes marker and a photo count. Edits you haven't saved survive the file being refreshed in the background. A file's photos are deleted with it when the trash is emptied. +- **File Manager previews open larger, zoom, go fullscreen and cover images (#2976, requested and contributed by @Thomansky in #2990)** — PDF, spreadsheet and 3D previews now open in the same large window, with a fullscreen button; double-click the preview to toggle fullscreen. PDFs zoom with Ctrl/⌘ + wheel, a trackpad or touch pinch, or the `+`, `-` and `0` keys. In the 3D viewer the wheel no longer zooms past the model into empty space, and the new zoom and reset buttons return to the framed view. PNG, JPG, GIF, WebP and BMP files get a preview with zoom and pan; TIFF keeps its thumbnail but no preview, because most browsers cannot show it. Double-click a file in the grid or list to open its preview, or select one file and use **Preview** in the toolbar. PDFs get **Generate Thumbnail** in the card menu and list row, like STLs. PDFs with Japanese, Chinese or Korean text, JPEG2000 images or ICC colour now render correctly; they came out blank or wrong before, because pdf.js could not load its font maps and image decoders. The page's security policy no longer allows WebAssembly; only the two preview workers that need it do. Very large zooms on iPhone and iPad stop gaining sharpness instead of showing a blank page. All previews run in the browser, not on the Bambuddy server. - **Find a spool by its label number, and assign it to a printer slot from the spool itself (#2978, requested and contributed by @pd81 in #2998)** — Typing `#3` in the inventory search now finds only spool 3. A plain `3` still matches anything containing it, such as #13 or #30, so existing searches behave as before. The spool pickers show each spool's `#ID`, so identical spools can be told apart. A spool with no slot now has an **Assign Spool** button in its editor, which is also where scanning a spool's QR code lands. It opens one dialog to pick the printer, then the AMS, AMS-HT or external slot. Nothing changes until you confirm, and a material mismatch still asks first. The SpoolBuddy kiosk offers the same printer choice when no printer is selected, instead of reporting it disconnected. On dual-nozzle printers the left external slot was labelled "Ext-R" in the assignment confirmation; it now reads "Ext-L". The AMS slots in the dialog can be picked with the keyboard, and screen readers announce each slot by name. - **Ambient drying can wait until the humidity stays high, so opening the AMS lid no longer buys a drying cycle (#2518, requested by @ryansouza, contributed by @M2ABRAMSTANK in #2895)** — Ambient auto-drying started on the first humidity reading above the threshold. Opening the AMS lid to swap a spool lets room air reach the sensor for a few minutes, and that brief spike was enough to start a cycle of up to 12 hours. A new **Require sustained humidity** switch in the **Queue Auto-Drying** card under Settings → Workflow, shown while **Ambient drying** is on, sets how long the humidity has to stay above the threshold before a cycle starts: 5 to 240 minutes, 15 when first switched on. It is off by default, so nothing changes until it is turned on. The 15 minutes come from a measured trace on an H2D: after the lid had been open for one to five minutes, every unit was back at or below 25% within about 12 minutes of opening, and the wait only starts counting at the first high reading. One reading at or below the threshold starts the wait over, and so does a gap of more than two minutes without a reading (a disconnect, or a print on a printer that isn't drying while printing), which is logged with the measured gap. A missing reading changes nothing. A printer with a scheduled print pending still starts at once, for every AMS unit on it, because that drying has a deadline. The wait also applies to a printer that is printing with **Continue drying while printing** on, but not while ambient drying itself is off. It runs alongside the cooldown after a cycle instead of being added to it, never interrupts a running or manual cycle, and does not log a wait for a unit the printer refuses to dry. Translated in all locales. - **Other applications can send messages through your notification channels** — A new switch per notification provider, **Messages from connected apps** (off by default), lets an application such as Bambuddy Orders send its own messages ("3 orders need you") to Telegram, ntfy, email and the other channels. The app calls `POST /api/v1/notifications/app-message` with an API key that has the new **Send Notifications** permission (off by default; the key's owner also needs `notifications:update`). Messages go through quiet hours, the daily digest and the notification log like Bambuddy's own. Text is plain, links must be http(s), and each key is limited to 20 messages a minute. `GET /api/v1/notifications/app-message/channels` tells the app which channels take its messages. The check behind the electricity-price door and this one is now one shared helper. +- **A link can open one batch order** — `/queue?batch=` opens the Batches tab with that order highlighted and scrolled into view, whatever its status. - **The streaming overlay can show the printer model (#3080, requested and contributed by @adamspicedev in #3099 and #3134)** — Settings → API Keys → Streaming Overlay has a new **Printer model** checkbox, which adds `model` to the overlay URL's `show=` list. It can be picked together with the printer name or on its own. With both, the overlay shows `Big Mumma · H2D`; with only the model, `H2D`. A printer with no model stored shows the name alone, without a stray separator. The model is off by default, so every overlay URL already set up in OBS looks the same after upgrading. The token-authenticated feed that OBS reads (`/printers/{id}/overlay-status`) now returns `model` for connected and disconnected printers; it exposes nothing else new. A long name plus model in a narrow OBS source is now cut off with an ellipsis instead of running past the edge of the panel. A printer saved by an older version under a discovery code such as `BL-P001` or `O1D` shows its model name (`X1C`, `H2D`), as on the Printers page, instead of the code. - **A second streaming overlay design, for portrait and landscape sources (#3177, requested and contributed by @adamspicedev in #3183)** — Settings → API Keys → Streaming Overlay has a new **Artwork** choice: **Classic**, the overlay as it has always looked, or **Version 2**, which adds `artwork=2` to the overlay URL. Version 2 lays itself out for the browser source's shape. In a landscape source the camera fills the frame under a translucent panel at the bottom; in a portrait source (1080 × 1920, for vertical streams) the printer name sits in a header, the camera fills the middle and the print data sits in a panel below it. Portrait crops the camera to fit rather than stretching it, so the sides of the build plate can be cut off. The fields, text size, frame rate, camera rotation and Streaming Overlay token all work as before, and a camera turned 90° still fills its space in either layout. It hides progress, layers and temperatures while the printer is offline, and a finished or failed print shows its status instead of "Printer is idle". The URL names a version number rather than something like "new", so a scene set up today keeps its look when a later design arrives. Classic stays the default, so every overlay URL already in an OBS scene looks the same after upgrading. - **Queue cards show the filament each job will print with (#3132, requested and contributed by @bgrr74 in #3184)** — Each job in the print queue now shows a colour swatch and name for every filament it uses, so you can check the next colours without opening each job. When a job is mapped to AMS slots on a specific printer, the card names the slot, the spool bound to it and its colour, for example "A1 · eSUN PLA Basic · Bone White", read from the printer's current AMS contents. Jobs without a slot mapping yet, such as "Any P2S", show the colour chosen when queueing, or the colour from the 3MF for the selected plate. Jobs with more than two filaments show just a row of swatches, with the full list on hover. If a mapped slot has been emptied since the job was queued, the card says so in yellow ("A3 · Empty · Caramel"); a spool without an RFID tag is never reported as empty. - **An app shown in the sidebar can match Bambuddy's theme** — An external link that opens inside Bambuddy now tells the framed page which theme is showing: light or dark, background, style and accent. It sends this when the page loads, whenever the theme changes, and when the page asks with a `bambuddy:theme-request` message. The message is `{type: "bambuddy:theme", mode, style, background, accent}` and goes only to the link's own origin. It carries nothing but those names. Pages that don't listen are unaffected. +- **An app shown in the sidebar can open a Bambuddy page in place** — The frame Bambuddy shows an external link in can't navigate the Bambuddy window, so a link such as Bambuddy Orders' "In Bambuddy" did nothing when clicked inside the sidebar. The framed page can now ask with `{type: "bambuddy:navigate", path: "/queue?batch=12"}`, and Bambuddy opens that page as if its own menu had been used. The request is accepted only from the frame, only from the link's own origin, and only for a path inside Bambuddy; anything else, such as another site or a `javascript:` address, is ignored. The theme message now carries `canNavigate: true`, so a page can tell this Bambuddy from older ones and open a new tab there instead. The frame's sandbox is unchanged. +- **Post-print outcome confirmation: say whether a completed print is actually a good part (#1898, requested by @FedericoPuntelli, contributed by @Thomansky in #3047)** — The printer can only report that a print *completed*; a warped, out-of-tolerance or wrong-colour part counted as a success anyway. A print can now ask for a verdict: turn on **Ask for Outcome** in the print dialog, or set it as the default under Settings → Workflow → Queue & Dispatch → Default Print Options, and when the print finishes Bambuddy asks "How did your print come out?" with the finish photo and **Good** / **Reject**. It can be answered in the dialog that opens in the web UI, from the printer card next to the plate-clear button, or from the phone: the new **Outcome Confirmation** notification event puts Good/Reject buttons into ntfy and Telegram notifications and a link to the dialog into every other channel. A reject takes an optional reason from the failure-reason list and offers **Print again**. *Ask me later* leaves an amber **outcome?** badge on the archive card, and the Archives page gets an **Unconfirmed** filter; the verdict stays editable from the card's menu or Edit Archive, and says where it came from (in the app, a link, the printer card, or a plate release). Two further options, both off by default: **Also ask for prints started outside Bambuddy** gives prints started at the printer, in Bambu Studio or in the Handy app the same prompt, and **Count unanswered outcomes as good on plate release** (Plate-Clear Confirmation card) records a still-open prompt as good when the plate is released. Rejected prints don't touch the machine failure rate; Failure Analysis shows them as their own line with a yield figure, and project part counts only count parts that were not rejected. A one-tap link shows a confirmation page and records nothing on a plain open, so a link preview, a mail scanner or a browser prefetch can't answer the question for you; only the notification buttons record in one tap. Nothing changes for anyone who doesn't turn it on: the option is off by default, and the notification event only fires for prints that asked. - **Connected apps: other applications can sign people in with their Bambuddy account** — Settings → API Keys → Connected Apps registers an app with one exact callback URL and gives it a client ID and a secret, shown once. The app sends the browser to `/connect/authorize`; the user confirms once ("Allow"), and after that sign-in is automatic, so an app opened from the sidebar needs no second login. The app's server then swaps a single-use code for the user's name, email, groups and permissions at `POST /api/v1/connect/token`. It is the OAuth 2.0 authorization-code flow with PKCE (S256 only), kept to what an app on the same network needs: a code lives 60 seconds, works once, is bound to its app, callback URL and PKCE challenge, and is redeemable only with the app's secret; codes and secrets are stored hashed, failed exchanges are rate-limited per app and per IP, and the page never redirects to a callback URL the backend has not matched against the registration. The app never receives the user's password or Bambuddy session, and an API key cannot sign its owner in to an app. Connected apps require authentication to be enabled; with it off, both endpoints answer `auth_disabled` so an app falls back to its own login instead of letting everyone in. The consent page may be shown inside Bambuddy's own frames (`frame-ancestors 'self'`), so an app opened from the sidebar can sign in there; every other page still refuses all framing. See the wiki page *Connected Apps*. - **A batch can record the external order it fulfils, so an integration can't queue the same order twice** — `POST /queue/batches` accepts `external_source` (e.g. `shopify`) and `external_ref`, a reference to the record in that system; both are returned on every batch and can be filtered on with `GET /queue/batches?external_source=&external_ref=`. The pair is unique: a second create for the same record answers 409 instead of making a second batch, so a connector that retries after losing the response can't print an order twice. Both fields are optional and must be given together; batches created without them behave exactly as before. +- **PDFs in the File Manager get their thumbnail the moment they arrive (#2976)** — A PDF's grid thumbnail used to exist only once somebody had opened its preview, because the browser rendered it and posted it back; until then the card was blank. Page one is now rendered on the server with PDFium (`pypdfium2`, a new dependency whose wheels carry the PDFium binary for every platform Bambuddy ships, so no system package is needed) on upload, inside an extracted ZIP and when an external folder is scanned. **Generate Thumbnails** covers PDFs too, so existing ones can be backfilled in one click; its tooltip and its nothing-to-do toast say so in all 15 languages. The thumbnail has the same shape as the browser's (a 256 px square, page centred on white), so old and new cards match. PDFium is not thread-safe, so renders are serialised behind a lock and run off the event loop; the render scale comes from the page's own size, so a PDF declaring a huge page cannot make it allocate a huge bitmap. A PDF it cannot read (damaged, encrypted) gets no thumbnail and still lands in the library, and the browser preview remains the fallback. - **Colour swatches for printer slots in the Print / Schedule dialog's filament mapping (#3159, requested by @frantiseklorenc)** — The dialog's filament mapping compares the colour the slice asked for against the colour actually in the machine, and until now only the left-hand side of that comparison had a swatch. The right-hand side — the slot, and every slot in its dropdown — was text, and the text is not reliable: a slot's colour name comes from the Color Catalog or, failing that, from hue, so a third-party beige is announced as "Orange". On a farm running twenty or thirty non-Bambu colours that are swapped between machines daily, a "Color mismatch" warning then gives no way to tell a real mismatch from two names for the same hex without opening the printer card in another tab. Each slot now carries its colour, in the picker and on the row, together with its hex; the slot whose colour is exactly the one the slice asked for is ticked. It works for a slot bound to an inventory spool and for one configured through Configure Slot or on the printer itself, which is the distinction that mattered — the second kind has no inventory row behind it and draws the colour the printer reports. A bound spool also contributes what a tray record cannot carry: its remaining colour stops and its effect, so a two-tone or glittery spool draws as itself rather than as its base colour. The same treatment is applied to the filament-override picker used for model-based assignment, which is the same choice on the other dispatch path and would otherwise have stayed text-only. Both controls stop being `` holding a value none of its options match is reset by the browser to the first one, which here reads "No project", so an archive in an archived project would have stated that it was filed nowhere. The one picker deliberately left alone is the parent-project picker, where a finished or archived project is still a legal parent. Fixed alongside it: the status tabs on the Projects page counted only the projects the selected filter had already let through, so every tab but the current one counted zero and lost its badge entirely — the reporter's own screenshot shows "Active 5" beside a bare Completed and a bare Archived, with thirty projects behind them. They are counted from the whole fleet now. Covered by frontend tests. +- **The full-page G-code preview grew without limit and never drew anything (#2887, reported by @ojimpo)** — Opening a 3D Preview from Archives left an empty white pane with the legend and layer slider floating over it, while the page's scrollbar shrank for as long as it stayed open — about 190px of page height per second, without stopping. Two faults compounded. The viewer appends its canvas into the very element it measures and watches for resizes, and three.js writes each new size onto the canvas as inline style, leaving it `display: inline` so the line box adds descender space on top; on a page where that element takes its height from its contents, the canvas was sizing the box that sizes the canvas, gaining a fixed 33px every round. And the page never gave it a height to take instead: the viewer pane is `flex-1 min-h-0`, which divides nothing unless the column above it is a definite height, and `h-full` is a percentage that resolves against a main area whose own height comes from a `min-height` — a floor, not a size — so it fell through to the content. Nothing was ever drawn because each observer callback reallocated and cleared the frame buffer before an antialiased render of what had grown to roughly 18 megapixels could finish. The canvas is now positioned out of flow, so it cannot contribute to the height of the element that measures it on any page, present or future, and that element takes a definite height from the pane around it rather than a percentage; the page itself is sized from the viewport the same way the File Manager page already was. The same viewer inside the File Manager dialog was never affected — a dialog gives it a fixed height, so neither fault could arise there. Nothing was wrong with the data path at any point: the legend and the layer slider were built from the parsed toolpath throughout, so the fetch, the parse and the layer split had all succeeded. +- **A print could not start on the nozzle sitting in the H2C's rack (#2885, reported by @apizz)** — A job sliced for a 0.2mm nozzle failed in the queue with "install the matching nozzle before printing", even though a 0.2mm nozzle was in the tool-changer rack and the printer would have fetched it. Only picking the nozzle up by hand on the printer's own screen first let the print run, and because the item failed rather than waited, the rest of the queue went with it. The reporter noticed the giveaway: going *to* 0.4mm always worked, going *to* 0.2mm never did. The nozzle-diameter guard that catches a genuinely wrong slice before upload was measuring the wrong thing — it compared the sliced diameter against the two mounted hotends only, so on a machine whose hotends both read 0.4mm nothing but 0.4mm could ever pass, and the rack picker that would have fetched the right one runs further down the same dispatch and never got the chance. The rack now counts as reachable: a diameter parked in any dock satisfies the guard the same way a mounted one does. This was never only about 0.2mm — a 0.6mm slice was blocked identically. A diameter that is in neither a hotend nor a dock still stops the print before it uploads, and the message now lists both sets so it is clear what the machine actually has. Also fixed alongside it: an empty carriage keeps reporting the diameter of the nozzle it last held, so a hotend that had parked its nozzle back in the rack was counted as a mounted 0.4mm that was not there — presence is now read from the nozzle's own temperature rating and serial number, and a hotend is only discarded when both agree it is empty. Printers that report no rack at all are unaffected. +- **An AMS slot could name the wrong white** — A hex is not one colour in Bambu's range: `#FFFFFF` is Jade White in PLA Basic, Ivory White in PLA Matte and plain White in six other materials, and `#000000` is Charcoal in PLA Matte where it is Black everywhere else. The slot popover looked the colour up by hex alone, and the lookup table can only keep one name per hex — so an ivory Matte spool was titled "Jade White" even while the profile line beside it correctly read Matte Ivory. The colour map now also carries the names that collapsing loses, keyed by material, and every slot resolves its colour with the material the printer reports for it (`tray_sub_brands`). Slots with a spool assigned from Inventory are titled with that spool's own colour name, which is the roll the user actually put in. Only a name the same brand's own range lost is carried — one manufacturer's name must not displace another's — so the added map is 11 entries against the 608 in the shipped catalog. +- **A queued job switched on printers that could never have printed it** — When no printer of the target model was available, the queue powered one on via its smart plug, but chose it on model alone: it walked the farm in printer-ID order, woke the first machine with an Auto On plug, and only then discovered the loaded filament was the wrong colour. A job for a colour loaded at the far end of the farm therefore woke every earlier printer in turn and left each one running until its own auto-power-off timer expired. The colours were known the whole time — a printer keeps its last reported AMS and external-spool trays after the power goes — so the wake step now asks the same three questions the matcher asks a live printer (required types, forced colours, preferred colours) and passes over a printer whose last known filament cannot satisfy the job. A printer Bambuddy has never heard from is still woken: no reading is not the same as no filament. Reconnecting a printer also no longer discards that reading, so a power-on attempt that times out stops erasing what the next attempt needs. +- **Archive metadata could describe a plate that was never printed** — The layer height on the archive card and in the library's file details came only from the 3MF's `project_settings.config`, and the plate G-code beside it was read for the layer count alone — the first `.gcode` entry in the zip, whatever plate the archive was actually for. A multi-plate export therefore reported plate 1's layer count even when plate 3 ran, and nothing ever cross-checked the layer height against the plate that produced the print. Both now come from the printed plate: its G-code is read (64 KB, enough to reach the config block that carries `layer_height` 14–25 KB in, where 4 KB only ever reached the header), and its value wins over the project's where the two disagree. Source 3MFs, which carry no G-code, keep the project value exactly as before. +- **Slicing a file could ignore the process preset you picked** — Bambuddy carries a designer's own process deviations across a re-slice (#2622) and pre-ticked all of them except the machine-coupled ones. `layer_height` is one that MakerWorld projects routinely carry, so picking "0.08mm High Quality" for a file whose designer had moved layer height to 0.2 sliced at 0.2 while the dropdown still read 0.08 — the same 0.2 the settings panel showed, tagged "from file". Layer height and first layer height are now treated like the machine-coupled keys: still offered, never pre-selected, and their badge in the settings panel names the conflict and shows the preset's own value beside the file's, so ticking one is a deliberate choice. +- **Statistics forgot the name of a printer that was deleted with its history kept (#2873, reported by @rembomy)** — Prints by Printer, the per-printer success breakdown, the time-accuracy list and Failures by Printer all resolved the name against the printers that exist right now, so deleting a printer and choosing to keep its prints turned "Ultron" into "Printer 1" everywhere. The runs themselves already recorded the name they printed on, and that is what those breakdowns fall back to now: the last name the id was known by, for as long as its prints are kept. A printer that still exists is named from its own record as before, so a rename shows up immediately rather than after the next print. Covered by backend and frontend regression tests. +- **Skip Objects went dead for the rest of a print if Bambuddy restarted while it was running** — The object list lives in memory and is filled by the print-start path, which is deliberately suppressed on the first status push after a restart so the print is not archived twice. Everything else that moment restores — the archive, filament attribution, the timelapse baseline — came back; the object list did not, so the printer card saw zero objects and greyed out its Skip button. Measured on the maintainer's H2C: 8 objects loaded at 09:02, a restart at 09:17, and no way to skip anything for the remaining hour of the print. Nothing could recover it either, because the one endpoint that can rebuild the list is only reachable from the modal that the greyed-out button opens. The list is now restored on the way back up, from the archive of the print that is still running and matched on the job id the printer mints per print, so a stale archive cannot lend its objects to someone else's job. Two things behind it changed as well: rebuilding now reads the archived 3MF on disk before asking the printer for a file Bambuddy already has — that request was a full transfer off a machine mid-print, 15 MB in this case, and it cannot succeed at all on a printer that kept the file on internal storage — and the card now treats zero objects as "not loaded yet" rather than "nothing to skip", since a running print always has at least one. A single-object print still greys the button out, which is the case that rule was written for. The plate image in the modal came from the same place and had the same problem: the cover, the top view and the object-ID mask all re-fetched the 3MF from the printer after a restart, three fan-outs at once for one modal, so the picture arrived seconds after the list. They now read the running print's archived file too. Wiki updated. Covered by backend and frontend tests. +- **A print archived without its 3MF can be given its filament weight by hand (#1820, reported by @ojimpo)** — When the sliced file stays somewhere Bambuddy cannot read, the archive is created from the printer's report alone and carries no weight, so the print is missing from every filament total and there was no way to put it right afterwards: Rescan reads the figure out of the 3MF, and that archive has no file to read. The reporter's H2S print left 46 g of PLA on the spool with nothing recording it, and he corrected Spoolman by hand. **Edit Archive** now has a **Filament used (g)** field. It is written to the print's most recent run as well as to the archive, because the Projects roll-up and the Prometheus counter sum the runs rather than the cards — correcting only the card would have fixed the display and left every aggregate reading the old figure. The value is bounded at 0 to 100 kg, it is sent only when you actually change it, so an ordinary save cannot round off a sliced figure, and emptying the field clears it. A run that measured its own weight through spool tracking keeps that measurement — the correction fills in a run that has none, or one that only ever inherited the archive's estimate, and never overwrites a real measurement with a typed one. Nothing is deducted from Spoolman or internal inventory either: those are charged from what was tracked at the time, and a print that recorded nothing has nothing to reverse. On an archive that does have its 3MF, Rescan still overwrites what you typed — there the file is the authority. Translated in all locales; wiki updated. Covered by backend and frontend tests. +- **The internal-storage probe now logs which directory served the file (#1820)** — When a printer says a print went to internal storage and Bambuddy finds it over FTPS anyway (#2856), the log said which file but not where it came from. On a printer that keeps uploads for weeks — the reporter's H2S has months of them in `/cache` — a reprint of a name that was re-sliced but never re-sent can match an older copy, and without the directory in the log that mismatch was invisible rather than merely rare. The download helper now reports the path that served the file instead of a bare success flag, and the hit line names it. Covered by backend tests. +- **Card and row actions were unreachable on phones and tablets (#2865, reported by @aishlai)** — The three-dot menu on a project card carries Edit and Delete and appeared only on hover, so on a phone there was no way to rename or delete a project at all; the File Manager's folder actions went the same way, as did duplicating a preset, renaming or deleting a tag, removing a print photo and deleting a plate-reference image. This is worse than a missing hover event: Tailwind v4 compiles `hover:` and `group-hover:` inside `@media (hover: hover)`, so on a touch-only device the rule that reveals the control is not merely never triggered, it is never applied — the element stays invisible for good. The hiding half is now what depends on a hover-capable pointer, so a device that cannot hover simply shows the control, and a mouse behaves exactly as before. Four spots on the Archives cards and two on the File Manager's file cards already tried to handle this by viewport width, under 768 pixels, which meant a phone was fine and a tablet in landscape was not; they now use the same capability check and the width guess is gone. Keyboard users were affected too, in the other direction — the buttons were invisible but still focusable, so tabbing through a card stopped on something nobody could see; focus now reveals them. Wiki updated. Covered by frontend tests. +- **The build plate of a powered-down printer can be cleared again (#2864, reported by @bryanmahin)** — With Auto Power Off enabled this is the ordinary end of every print: the job finishes, Bambuddy switches the printer off at the plug, and the plate is left flagged dirty on a machine that is no longer reachable. The operator then walks over, clears the plate, and had no way to say so — `POST /printers/{id}/clear-plate` answered 400 "Printer not connected", and the button was hidden on the printer card, so the physical clear-plate buttons some farms drive over the API went dead too. Everything gated on the flag stayed stuck until each printer was powered back on by hand, cleared, and switched off again, which is the opposite of what Auto Power Off is for. Nothing in clearing a plate talks to the printer: the flag is Bambuddy's own state, persisted in its database precisely so it survives the power cycle, and the check that refused was inherited from the stop, pause and resume handlers next to it, where reaching the printer genuinely is required. It is gone, and the card offers the control whether the printer is online or not — in both card sizes, and for bulk selections, where powered-down printers were being filtered out of a Clear All. This does not dispatch work to an unreachable machine: the queue still requires a live connection before it sends anything, and releasing the gate is what lets it power the printer back on for the next job instead of skipping it. One related gap went with it — a printer with no live connection at all, disconnected by hand or not yet reconnected after a restart, reported its plate as clean over the API regardless of what the database said, which hid the control on exactly the printers that needed it. Wiki updated. Covered by backend and frontend tests. +- **H2D archives lost their 3MF, thumbnail and filament data after the last update (#2856, reported by @aishlai)** — When a print starts, the printer says where it put the sliced file, and Bambuddy took `brtc://emmc/` — internal storage — as proof there was nothing to fetch, so it archived the print by name alone. On an H2D with a card in the slot that is not true: the same file sits under `/cache` and downloads without complaint, as it had for that reporter's every print until the change landed. Bambuddy now checks instead of assuming. The printer names the exact file, so confirming it takes one connection across five paths rather than the ~110-connection search that made skipping worth doing — and when the file really is out of reach, as it is on an H2C or P2S with no copy on the card, the archive falls back exactly as before and still says why. The connection diagnostic asks the same question before warning that a print is out of reach. Covered by backend tests. +- **A printer with more than one smart plug never recorded energy or energy cost (#2859, reported by @sn8key)** — Linking a second plug to a printer — a dry box, a filter fan, a chamber light you want to switch from the printer card — stopped energy tracking on that printer completely, from the moment the second plug was linked. Per-print energy is the change in one plug's meter between the start and the end of a print, and both readings asked for "the plug on this printer" in a way that accepts one answer and fails on two. The failure was indistinguishable from having no plug at all: the print-end log said no starting reading had been taken, which is also what it says for a printer with nothing linked to it. Bambuddy now picks the printer's own plug — the one marked as supplying its power, and of those the one that actually reports a meter, so accessories drop out on their own — and names the plugs it tried when none of them measures anything. Linking several plugs to a printer is supported and always was; only energy assumed otherwise. Past prints cannot be recovered, since the starting reading was never taken. Covered by backend tests. +- **The smart plug page counted an online plug as offline unless it reported energy (#2859)** — A plug with no power sensor is still online, but "N/M plugs online" only counted the ones sending energy figures, so a working switch showed as offline for as long as it stayed linked. The count now reflects whether the plug answers. +- **Reprints of a file already on the printer archived without their thumbnail or filament data (#2780 regression)** — Printing a file that is already on the printer — from the printer's own screen, from Handy, or after sending it to storage from a slicer and pressing print — reports the file by its path rather than by how it got there. Bambuddy read anything that was not a fresh upload as "the printer kept this on internal storage", stopped looking, and archived the print with only its name and timing. The file was on the card the whole time: on the machine this was measured on, `/media/usb0/foobar.gcode.3mf` was listable and downloadable over FTP at the moment Bambuddy decided it was unreachable. This affected every model, not only the H2 series and P2S the original change was about, and it arrived with that change on 2026-08-14 — before it, those prints archived normally. Bambuddy now reads the path: the printer's own internal model cache is still skipped, since nothing there is reachable, and everything else is looked for as it always was. Covered by backend tests. +- **Prints sent from a slicer were sometimes logged as though Bambuddy had sent them (#2843 follow-up)** — Bambuddy records the dispatch behind every print so a support bundle shows where the sliced file went, and it told its own dispatches apart from a slicer's by a sequence number it believed was unique to it. It is not: that number is the slicer convention Bambuddy adopted, and measured on the wire OrcaSlicer counts from it while Bambu Studio counts from the same base a few higher. Whichever dispatch happened to land on the shared value was filed as Bambuddy's own and never recorded — after a slicer restart, that is the first print you send. Bambuddy now recognises its own dispatch by the job it actually sent. Nothing about printing or archiving changed; the entry was diagnostic, but it is the entry that tells you whether a printer stores your files somewhere Bambuddy can read them. Covered by backend tests. +- **A print with no 3MF could take its filament figures from an unrelated model (#2843, reported by @gyrene2083)** — Bambu Studio sends a sliced file to the printer's internal storage on H2-series and P2S, which Bambuddy cannot read, so those prints archive without a 3MF ([BambuStudio#10481](https://github.com/bambulab/BambuStudio/issues/10481) tracks that default upstream). Bambuddy then looks for the same model in your Library or among earlier prints, which is how a reprint still gets its filament accounted for. The name it searched on was the wrong one. A running print reports the file it is executing — always `Metadata/plate_1.gcode` — and with no 3MF to correct it, that path became the archive's name and `plate_1` became the search term. Every Bambu print has a plate 1, so the search matched on nothing meaningful and took whatever came back: on the maintainer's H2D a 1.6 g Cube was costed from a 207 g four-colour ABS print whose file happened to be named `lid_plate_1.3mf`. The match now uses the model name the printer reports alongside the plate path, a plate name on its own is refused rather than searched for, and a name must match a whole filename instead of merely appearing inside one. A print that cannot be identified is left untracked, which is the honest answer — the previous behaviour was to charge your spools for a model you did not print. Covered by backend tests, including the exact collision measured on the H2D. +- **Timelapses were lost, and written outside the data directory, for any print archived without a 3MF (#2843)** — Every H2-series and P2S print sent from Bambu Studio, so not a rare case. The video downloaded from the printer correctly and was then written next to the data directory rather than inside it, because an archive with no 3MF has no directory of its own and the destination was derived from the missing file's path. In Docker that meant a permission error, retried and discarded twenty-five times over twelve minutes, roughly a hundred connections to the printer for a video that was thrown away each round. Where that location happened to be writable it was worse: the file landed beside the installation, the attach failed anyway, and the stray video stayed there. Bambuddy has had a shared helper for exactly this since #1820 and this was the one place still deriving the path by hand. Timelapses now land in the archive's own folder and attach normally. Covered by backend tests. +- **A slot that could not be charged now says so (#2843)** — When a print's filament cannot be read from a 3MF, Bambuddy falls back to the drop in the AMS's own remaining-filament percentage. That needs a reading when the print starts, and a spool without RFID has none until you set a remaining amount by hand — so those slots were skipped in silence. Nothing was deducted and nothing said why, which is indistinguishable from having nothing to deduct. Every other reason for skipping a slot was already logged; this one now is too. +- **PostgreSQL installs on a non-UTC timezone showed AMS History and Archive timestamps hours in the future (#2855, reported by @Tolga-Unal)** — On UTC+3 every AMS humidity reading and every archive was stamped three hours ahead of when it happened. Bambuddy stores timestamps without an offset and treats them as UTC everywhere, and the Python side has done so since #504 — but around a hundred and fifty timestamps are not written by Bambuddy at all. They are database defaults, filled in by the database, and PostgreSQL fills them from a clock whose timezone is the server's own. A Postgres container started with `TZ=Europe/Istanbul` bakes that zone in when the cluster is created, so those columns received local wall-clock while everything reading them assumed UTC, and the display added the offset a second time. Bambuddy's connections now pin their session to UTC, so what the database writes matches what the rest of the product means, whatever the server is set to. SQLite was never affected — its clock is UTC by definition, which is why this hid for as long as it did, and the fix makes Postgres agree with SQLite rather than inventing a third convention. **Timestamps already recorded are not rewritten**: which of them were written by the database and which by Bambuddy cannot be told apart after the fact, and an install that started on SQLite holds both kinds. Everything from the upgrade forward is correct; older rows keep the times they were given. One related mismatch went with it — the support package's "oldest pending queue item" age subtracted a local clock from a UTC column, reporting a job queued five minutes ago as three hours old east of Greenwich and a negative age west of it. Covered by backend tests, including one that reproduces the reporter's three-hour shift. +- **K-profiles follow an AMS when it moves between Filament Track Switch inlets** — K-profiles are calibrated per nozzle, and the printer numbers its calibration table per nozzle too, so entry 16 exists on both hotends and means a different profile on each. An AMS tray, however, holds exactly one index. Move an AMS to the switch's other inlet and every configured slot in it silently keeps pointing at the old hotend's table: on the maintainer's H2C a black PLA calibrated 0.018 on the left and 0.020 on the right stayed on the left profile after the move, and a manual RFID re-read only re-asserted the same wrong one. Bambuddy already stores both profiles for a spool, so the move now re-selects the counterpart for the nozzle that AMS actually feeds. Only the calibration binding changes and only for slots whose spool already has a profile for the new nozzle — configuring a slot is a deliberate preparation step, so a slot Bambuddy knows nothing about, or a spool calibrated on one hotend only, is left exactly as you set it. Nothing is re-applied on the first sighting of a binding either, or every reconnect would overwrite a choice made by hand. +- **Configure Slot picks the K-profile for the slot's own nozzle** — The profile dropdown identified an entry by name and K value alone, with nothing naming the hotend, so a filament calibrated on both appeared twice with no way to tell them apart, and two that happened to share a K value collapsed into whichever the printer listed first. The tie-break meant to prefer the slot's own nozzle was gated on a value that is never set on a machine with a Filament Track Switch, where no AMS reports a nozzle at all — so the pick was arbitrary, and the fallback for an unrecognised binding was simply the first profile in the list. Options now carry the hotend, matches are scoped to the nozzle the slot actually feeds (the other hotend's profiles remain available under **Other K profiles**), and the slot's active index is resolved against its own nozzle rather than followed into the wrong table. The K value shown per slot on the printer card was affected by the same confusion and is now resolved the same way. Covered by backend and frontend tests. +- **Auto K-profile calibration no longer leaves an archive behind** — When flow dynamics calibration is on, the printer lays down a pressure-advance line before the print itself. It announces that over MQTT through the same print-start event a real print uses, so Bambuddy archived it: a row named `auto_pa_line_calib_mode`, marked as having no 3MF, in among your actual prints, plus a "Print started" and a "Print completed" notification for each one. Bambuddy has always skipped the printer's other internal jobs, but only by spotting the `/usr/` path they carry — and this one arrives as a bare subtask name with no path at all, so it went straight past. It is now recognised by name, from either field the printer might report it in, and matched exactly so that a file you have deliberately named after the calibration is still your file. The completion is quiet too, which matters more than the noise: with no archive to close, it would have fallen into the path that attributes an unmatched completion to any job the printer finished in the last five minutes — and a calibration that runs alongside a real print would have told that print's owner it was done, early. Skipping the run early also saves the pointless FTP sweep for a 3MF that cannot exist, roughly a hundred connections to a printer that is mid-calibration. Covered by backend tests. +- **A printer being busy or offline no longer blocks printing from its card (#2849)** — Dragging a sliced file onto a printer card refused the drop unless the printer was connected and neither printing nor paused, showing a red "Printer busy" and discarding the file; the card's **Print** button disappeared under the same condition. The workaround was to upload to the File Manager and queue it from there by hand, which is the same thing with more steps. It was never a real restriction: every print Bambuddy sends becomes a queue item, and dropping onto an idle printer only looks instant because the scheduler dispatches it right away. Both routes now work whatever the printer is doing, and a busy one simply queues the job. Offline printers are included, so you can queue work for a machine that is powered down and have it dispatch when it reconnects. The overlay says which you are getting — "Drop to print" when the job would start immediately, "Drop to queue" when it would wait, covering a print in progress, a paused job, an AMS mid-drying and a plate not yet cleared. That wording comes from the same check the Print Modal uses for its own "will start later" notice, so the card and the modal cannot disagree. The drop is also now gated on the permissions the flow actually uses, **Library Upload** and **Queue Create**, matching the Print button beside it; it previously checked Printer Control, which meant someone holding that but neither of the others got the file uploaded and then rejected by the queue, leaving it stranded in the library. Translated in all locales; wiki updated. Covered by frontend tests. +- **Drying an AMS no longer sends an hourly "temperature high" alert for the whole cycle (#1802)** — The AMS temperature alert compares against the same threshold that colours the printer card, which defaults to 35 C, while drying deliberately runs at 45 C for PLA, 65 C for PETG and up to 85 C on an AMS-HT. The alert repeats once an hour for as long as the condition holds, so a twelve-hour dry sent twelve notifications about a temperature you asked for, and then kept sending them while the unit cooled back down. The alert is now held back for the length of a cycle and through the cool-down that follows it, using the drying state the firmware already reports rather than anything you have to configure. Suppression lifts as soon as the unit reads back at or below your threshold, so a 65 C cycle in a cold basement and a 45 C one in a warm room each get exactly the cool-down they need instead of a fixed guess. Two things are deliberately left alone: the humidity alert, which is the one you want during drying because the number falling is the point, and a unit reporting `HeatOutOfControl`, where an AMS that has lost thermal control is precisely when the alert should still reach you. Because a cycle plus its cool-down can outlast a restart, the suppression is stored rather than held in memory. No new setting — the alert simply stops firing for heat you asked for. Wiki updated. Covered by backend tests. +- **Closing the bug-report panel no longer throws the capture away and leaves the logs running (#2847)** — Step 2 of the report flow asks you to reproduce the problem, and the panel sits over the part of the app you have to reach to do it. Closing it was the obvious move and it was the wrong one twice over. Reopening put you back on an empty step 1 — while the server was still logging at DEBUG, with nothing left in the flow that could stop it, because only **Stop & Submit** ever did. Leave it closed instead and the five-minute cap eventually fired behind your back: logging stopped and the report was filed with no window open and no confirmation that it had happened. Which of the two you got depended only on whether you reopened the panel inside five minutes. A capture is now a thing that outlives the panel. Closing keeps it running and says so — the bug button turns amber for as long as a capture is going, and clicking it returns you to step 2 with your description, your screenshot and the elapsed timer where you left them. If the cap does fire while the panel is closed, the panel reopens so the submission happens in front of you rather than behind you. The timer is measured against the capture's start time rather than counted in ticks, so a background tab, where browsers throttle timers hard, no longer stretches five minutes into something else. A capture also survives a page reload, which matters because reloading is a perfectly ordinary step in reproducing a bug: the report picks it back up where it was. One that outlived the cap while nobody was watching is not resumed and not filed — a description written an hour ago is not a report you are still expecting — but the log level is put back, which is the part that previously stayed wrong indefinitely. Translated in all locales; wiki updated. Covered by frontend tests. +- **The File Manager's card menu no longer loses its top entry (#2846)** — In grid view a file card's action menu was drawn inside the card, and the card clipped anything its children painted outside it. A card is as tall as its square thumbnail plus whatever metadata the file has, so an STL — which has none beyond a name and a size — produced the shortest card in the library, about 270px against a seven-entry menu that needs closer to 310px. The difference was one row, and the row it took was the top one: **Slice**, since **Print** is only offered for a file that is already sliced. A 3MF carries a target model and a print count, two more rows, and its card was tall enough, which is why the button appeared there and looked like a file-type rule rather than a layout accident. Nothing about STL was special; the shortest card simply lost the first item, whichever it happened to be. The menu now opens against the viewport, the way the archive card's menu already did, so no card can crop it, and the card no longer clips its own children. List view was never affected — it has no menu, only inline buttons. Covered by a frontend test. ### Security - **Bumped the Tiptap editor stack to 3.31.1 for a prototype-manipulation advisory in `@tiptap/core` (GHSA-cp6q-959q-f8rh)** — `mergeAttributes()` copies keys straight out of `Object.entries()` with ordinary bracket assignment, so an own `__proto__` key coming from JSON invokes the legacy prototype setter instead of writing a property: the returned object carries an attacker-controlled prototype while `Object.keys()` and own-property checks show nothing. That matters because ProseMirror's `DOMSerializer.renderSpec()` enumerates attribute objects with `for...in`, which walks inherited keys — an inherited `src` and `onerror` become real attributes on a rendered `` and the handler runs in the app's origin. Medium severity, CVSS 4.0 6.4; the fix landed in 3.30.4 and the whole stack moves 3.19.0 → 3.31.1. **No running Bambuddy install was exposed.** The advisory needs either an untrusted object reaching `mergeAttributes()` or a custom/dynamic extension that preserves the attribute object, and Bambuddy has neither: nothing under `frontend/src/` calls `mergeAttributes` or defines an extension, and the one editor (`RichTextEditor`) builds a fixed schema from StarterKit plus six stock extensions whose `HTMLAttributes` are static literals. Content also crosses the boundary as an HTML **string**, never as JSON, so no own `__proto__` key can reach an attributes object in the first place — ProseMirror's DOM parser only fills in the attributes the schema declares — and every read-only render of that content is sanitized (`DOMPurify.sanitize` for project notes, `sanitizeHtml` in the project-page modal). This is a lockfile-only change: `frontend/package.json` already declared `^3.11.1`, so the patched line was inside the existing range and only the stale lock held it back; no `overrides` entry was needed. A side effect worth recording is that `@tiptap/pm` has narrowed what it pulls in, so `prosemirror-markdown`, `prosemirror-menu`, `prosemirror-collab`, `prosemirror-schema-basic`, `prosemirror-trailing-node`, `markdown-it` and `linkify-it` leave the tree entirely (16 packages) — which retires the reachability argument recorded for the `linkify-it` bump in 1.2.5, since that package is simply no longer there. Verified with eslint, the production build and its Safari 16 baseline check, and the full frontend suite (3514 tests across 256 files); `npm audit --omit=dev` reports zero vulnerabilities. - **Bumped the build and lint toolchain for three development-dependency advisories in `browserslist` and `@humanfs/node` (GHSA-73wf-gq98-2v4g, GHSA-c83g-rgw3-j3cx, GHSA-p498-v437-472g)** — `browserslist` moves 4.28.1 → 4.28.8 for two high-severity issues: `normalizeStats()` walks an untrusted `browserslist-stats.json` with an unguarded `for...in` and uses the keys for plain bracket access and assignment, so a `__proto__` or `constructor` key either crashes the build or writes to the prototype (CVE-2026-73088), and the query-result cache has no eviction at all, so a long-lived process fed distinct queries grows without bound (CVE-2026-73089). `@humanfs/node` moves 0.16.7 → 0.16.8 for a medium-severity path-traversal issue where `copyAll()` ignores symlink state and `fs.copyFile()` dereferences the link, copying data from outside the source tree. **No running Bambuddy install was exposed, and neither issue was reachable even at build time.** Both packages are development-only — they are absent from the shipped image, and `npm audit --omit=dev`, which is what CI gates on, reported zero findings before and after. `browserslist` is never called by our own code; it arrives under `autoprefixer` and `@babel/helper-compilation-targets`, there is no `browserslist-stats.json` anywhere in the repository or up the directory tree, no `browserslist` key in `package.json` and no `.browserslistrc`, and nothing passes `--stats` or `opts.stats`, so the untrusted input the first advisory needs has no way in; the unbounded cache needs a long-lived process taking attacker-chosen queries, where `vite build` is one-shot with a fixed query. `@humanfs/node` arrives under `eslint`, which calls only `isDirectory` and `walk` and never copies anything, so the copy path the advisory describes is never entered. Lockfile-only — every existing range already admitted the patched versions, so `frontend/package.json` is untouched. The bump carries `caniuse-lite` 1.0.30001769 → 1.0.30001810, `baseline-browser-mapping` 2.9.19 → 2.11.20, `electron-to-chromium` 1.5.286 → 1.5.420, `node-releases` 2.0.27 → 2.0.54 and `update-browserslist-db` 1.2.3 → 1.3.2, all of which feed autoprefixer's target data — the rebuilt bundle is byte-identical, same content hashes, so `static/` does not change. Verified with a clean build against the Safari 16.0 baseline check, eslint, 3514 frontend tests across 256 files, i18n parity in all 13 locales, and `npm audit` reporting zero findings with and without dev dependencies. -- **Bumped `fflate` to 0.8.3 for a denial-of-service advisory reachable through three's compressed-format loaders (GHSA-px8p-9vwx-vf98, #3034)** — `unzipSync()` treats a central-directory entry whose compressed size is the Zip64 sentinel `0xFFFFFFFF` as a Zip64 entry and scans its extra field for the header id `0x0001`, with nothing telling the scan where that field ends: `z64e()` then reads past the end of the buffer, the `undefined` that comes back coerces to 0, and the loop condition can never go false, so the tab spins at 100% CPU until it is killed (CVE-2026-45820, medium, CWE-400). **No running Bambuddy install was exposed.** Unlike the other two entries here this one is classified runtime rather than development scope, so it is worth saying exactly why it cannot fire: `fflate` reaches the tree only as a dependency of `@types/three`, which is a types-only package whose imports TypeScript erases at compile time, so it never becomes a runtime import at all. The ten three.js addons that genuinely call it — `3MFLoader`, `FBXLoader`, `KMZLoader`, `AMFLoader`, `EXRLoader`, `USDLoader`, `VTKLoader`, `NRRDLoader`, `USDZExporter` and `EXRExporter` — are imported nowhere in the frontend or the backend; the four this project does use, `OrbitControls`, `BufferGeometryUtils`, `STLLoader` and `RoomEnvironment`, reference none of it. `3MFLoader` is the one worth checking twice given what Bambuddy spends its time reading, and it is genuinely absent: 3MF files are parsed on the backend, not in the model viewer. The shipped bundle confirms it, carrying no fflate signature whatsoever — `unzipSync`, `z64e`, `invalid zip data`, `no stream handler` and `extra field too long` are all absent, and the one inflate error string that does appear belongs to pako, which the surrounding `e.msg=` / `n.mode=30` zlib-port idiom identifies unambiguously. Lockfile-only: three lines, no `frontend/package.json` change, nothing added or removed, and no transitive churn. The reason a package that never ships shows up in runtime scope at all is that `@types/three` sits in `dependencies` rather than `devDependencies`, which is left alone here rather than moved in a security bump. +- **Bumped `fflate` to 0.8.3 for a denial-of-service advisory reachable through three's compressed-format loaders (GHSA-px8p-9vwx-vf98, #3034)** — `unzipSync()`g `0x0001`: `z64e()` then reads past the end of the buffer, the `undefined` that comes back coerces to 0, and the loop condition can never go false, so the tab spins at 100% CPU until it is killed (CVE-2026-45820, medium, CWE-400). **No running Bambuddy install was exposed.** Unlike the other two entries here this one is classified runtime rather than development scope, so it is worth saying exactly why it cannot fire: `fflate` reaches the tree only as a dependency of `@types/three`, which is a types-only package whose imports TypeScript erases at compile time, so it never becomes a runtime import at all. The ten three.js addons that genuinely call it — `3MFLoader`, `FBXLoader`, `KMZLoader`, `AMFLoader`, `EXRLoader`, `USDLoader`, `VTKLoader`, `NRRDLoader`, `USDZExporter` and `EXRExporter` — are imported nowhere in the frontend or the backend; the four this project does use, `OrbitControls`, `BufferGeometryUtils`, `STLLoader` and `RoomEnvironment`, referencenone of it. `3MFLoader` is the one worth checking twice given what Bambuddy spends its time reading, and it is genuinely absent: 3MF files are parsed on the backend, not in the model viewer. The shipped bundle confirms it, carrying no fflate signature whatsoever — `unzipSync`, `z64e`, `invalid zip data`, `no stream handler` and `extra field too long` are all absent, and the one inflate error string that does appear belongs to pako, which the surrounding `e.msg=` / `n.mode=30` zlib-port idiom identifies unambiguously. Lockfile-only: three lines, no `frontend/package.json` change, nothing added or removed, and no transitive churn. The reasona package that never ships shows up in runtime scope at all is that `@types/three` sits in `dependencies` rather than `devDependencies`, which is left alone hererather than moved in a security bump. - **Bumped Vitest to 4.1.11 for a path-traversal advisory in `@vitest/mocker` (GHSA-82fw-gwwq-j7x9)** — the mocker registers a redirect mock's target path without checking it against Vite's file-serving allowlist, and the plugin's `load` hook then hands back `readFile(mock.redirect)` as the module source. Registration derives that path as `join(server.config.root, new URL(event.redirect).pathname)`, which does not confine anything: a non-special scheme leaves `..` segments in `pathname`, so the join resolves outside the project root, and even a path that stays inside it is read without the `server.fs.deny` check the dev server would otherwise apply to an in-root `.env`. Medium severity, CVSS 3.1 5.9, CWE-22; fixed in 4.1.11 and 5.0.0, with 2.1.x and 3.x unmaintained. **No running Bambuddy install was exposed, and the issue is not reachable in this project's test runs either.** The unauthenticated variant is specifically the public `mockerPlugin` and standalone `interceptorPlugin` exports, which attach the handler to Vite's HMR WebSocket — a socket with no token, Origin or same-origin check — and those exports exist for third-party dev servers embedding the mocker; nothing under `frontend/src/` imports either one. Vitest's own browser mode registers mocks over a token-authenticated RPC instead, and it is not installed here at all: `@vitest/browser` appears in the lockfile only as an unmet optional peer, and `vitest.config.ts` runs a plain jsdom environment, so no dev server is listening during a test run in the first place. Both packages are development-only — absent from the shipped image, and `npm audit --omit=dev`, which is what CI gates on, reported zero findings before and after. Fifteen lockfile entries move, every one of them dev-scoped, with nothing added or removed: the eight `@vitest/*` packages and `vitest` itself go 4.1.8 → 4.1.11, carrying `es-module-lexer` 2.1.0 → 2.3.2, `expect-type` 1.3.0 → 1.4.0, `obug` 2.1.1 → 2.2.1, `std-env` 4.1.0 → 4.2.0, `tinyexec` 1.2.4 → 1.3.1 and `tinyrainbow` 3.1.0 → 3.1.1. Unlike the other entries here this one does touch `frontend/package.json`: the existing `^4.1.8` range already admitted the patched version, so the lock alone would have pinned it, but the declared floor is raised to `^4.1.11` so that a regenerated lockfile cannot resolve back below the fix. Nothing in `src/` changed, so the rebuilt bundle is byte-identical and `static/` does not move. Verified with the full frontend suite running on 4.1.11 (3562 tests across 259 files), eslint, the production build and its Safari 16.0 baseline check, i18n parity in all 13 locales, and `npm audit` reporting zero vulnerabilities with and without dev dependencies. - - - -## [1.2.5.5] - 2026-08-30 - -### Changed -- **Updating now repairs a service file that was written before the `--loop asyncio` pin existed (#3001)** — `install.sh` has pinned the loop since 2026-07-05 (#1896), but nothing has ever rewritten an *existing* service file: `install/update.sh` does a `git reset --hard`, a pip install, a frontend build and a restart, and never touches the unit. uvloop has been in every native venv since `uvicorn[standard]` entered `requirements.txt` on 2025-11-28, and uvicorn's `--loop auto` prefers it, so every native install created in that seven-month window has been running on uvloop ever since and no amount of updating has changed that. It cost them every RTSP camera on 1.2.5.4, and before that it left them exposed to a Virtual Printer FTP upload being silently truncated into a corrupt `.gcode.3mf` — which is why #1896 shipped a ZIP-validation backstop alongside the pin, since the pin could never reach the installs that already existed. Both update scripts now add the missing flag themselves: `update.sh` to the systemd unit, `update_macos.sh` to the launchd plist, in each case while the service is stopped so the repair takes effect on the same restart. Only the one flag is ever inserted — a hand-edited port, extra hardening, `ExecStartPre` lines and everything else stay byte-identical, and the file is copied to a timestamped backup first. Anything that is not a single-line unit invoking uvicorn directly is described rather than edited: a wrapper script, an `ExecStart` continued across lines, several `ExecStart` lines, a unit that is not writable, or a service carrying systemd drop-ins, since a drop-in may be what defines `ExecStart` and editing the fragment would then change nothing while reporting success. A loop pinned deliberately is also left alone — someone who wrote `--loop uvloop` on purpose gets no argument, only the startup warning. The check reads the *effective* `ExecStart` from systemd rather than the file, so a drop-in that already pins the loop counts and the repair is idempotent. -- **Bambuddy now says so at startup when it is running on uvloop (#3001)** — every unit file this project ships pins `--loop asyncio`, added for #1896 because uvloop's SSL layer can drop buffered data and truncate a Virtual Printer FTP upload into a corrupt `.gcode.3mf` that is acked `226` and forwarded to a printer. Two populations run a unit nobody here wrote and therefore have no such pin: the Proxmox VE Helper-Scripts LXC, which composes its own `ExecStart`, and native installs created before that fix landed on 2026-07-05, which never gained the flag because `install/update.sh` does not rewrite unit files. Neither had any way to know. #3001 only surfaced them because losing every camera at once is loud; a truncated upload is silent, and there is nothing to notice until a print fails from a file that was corrupt on arrival. Startup now logs one WARNING naming the loop, the risk and the exact flag to add. It is a warning and not a refusal: a server that answers requests beats a purist one that will not boot, and by the time any application code runs uvicorn has already chosen its loop. The check asks the running loop what it is rather than whether uvloop imports — uvloop is a hard dependency here, since `requirements.txt` pins `uvicorn[standard]`, so its presence says nothing about what is in use — and it matches on the module name so that asking the question never imports uvloop on a host that lacks it. - -### Fixed -- **Every camera stopped working on 1.2.5.4 (#3001, reported by @Jieper001, confirmed by @JmanB52D and @hikingthunder)** — live view, snapshots, timelapse frames and the camera diagnostic all failed at once on every RTSP model — X1, H2 and P2 — with the in-app diagnostic reporting `capture_exception` at 0 ms while network reachability passed at 1 ms. That 0 ms is the whole story: the failure happened before a socket was opened. The RTSPS proxy added in 1.2.5.4 finished by hanging its set of in-flight connection handlers on the server object as an attribute. `asyncio.start_server` returns an `asyncio.base_events.Server`, which has a `__dict__` and accepts that; under uvloop it returns a `uvloop.loop.Server`, a Cython cdef class with no `__dict__`, which raises `AttributeError` outright. Every launch path this repo ships — the Dockerfile, `install/install.sh`, `deploy/bambuddy.service`, the Windows service and the SpoolBuddy installer — pins `--loop asyncio`, added for #1896, so none of them selects uvloop and none of them could hit this. What broke is the installs running a unit file we did not write. The Proxmox VE Helper-Scripts LXC composes its own `ExecStart` with no loop pinned, and `requirements.txt` pins `uvicorn[standard]`, which installs uvloop on Linux, so uvicorn's default `--loop auto` selects it — that is the reporter's install and the two that confirmed it. Native installs created before the #1896 pin landed on 2026-07-05 are in the same position for a different reason: `install/update.sh` never rewrites the unit file, so a service written before that date has never been given the flag by any update since. Those installs are also still exposed to #1896 itself, where a truncated Virtual Printer FTP upload corrupts a `.gcode.3mf` silently; the camera outage is simply the visible half. Hence a fix in the code rather than another flag in a unit file: the proxy now works on either loop instead of depending on the launch command to steer around it. The handler set now lives in a module-level registry keyed weakly by server, which both loops accept; keying it weakly rather than by `id(server)` means a proxy abandoned without a close takes its entry with it, instead of leaking one forever and eventually handing a new server a dead one's handlers once CPython recycles the address. A1 and P1 use the chamber-image protocol and return before the proxy is built, so they were never affected, and external RTSPS cameras caught the error and fell back to a direct connection, so they kept working without the TLS workaround. The reason the test suite could not see any of this is that `conftest` builds its event loop from the default policy, so every async test in the repo runs on the selector loop — the one loop where the assignment was legal. The regression is now pinned twice: once by a test that drives the real function on a real uvloop loop, and once by a test that gives it a `__slots__` server, so the contract holds even where uvloop is not installed. The two external-camera teardowns were also switched to `close_tls_proxy`, which #2968 introduced and left them out of, so they no longer leave handlers running past the server that owned them. +- **Bumped `js-yaml` to 5.4.2 for a merge-key denial-of-service advisory** — In versions up to 5.4.0, the `maxTotalMergeKeys` limit does not count empty mappings. A small YAML document that merges a long list of `{}` many times (`<<: *arr`) can keep the CPU busy for seconds per few hundred kilobytes without ever reaching the limit. It only applies when merge keys are enabled, which means the YAML 1.1 schema. The fix counts each merged mapping towards the budget. **No running Bambuddy install was exposed, and the issue was not reachable at build time either.** `js-yaml` is a development-only transitive dependency, pulled in by `eslint` through `@eslint/eslintrc`, and it is not in the shipped image. `@eslint/eslintrc` only parses legacy `.eslintrc.yaml`/`.eslintrc.yml` files, and this repository has none. Linting uses the flat `frontend/eslint.config.js`, so no YAML is loaded at all. The existing `overrides` pin in `frontend/package.json` moves from `^5.2.3` to `^5.4.1`, so a regenerated lockfile cannot resolve back below the fix. The lockfile changes in one entry only, with nothing added or removed, and the shipped bundle does not change. Verified with eslint, typecheck, the production build, and the full frontend suite (4022 tests across 297 files). `npm audit` reports no `js-yaml` findings. +- **Bumped `brace-expansion` to 5.0.12 for three denial-of-service advisories** — Two high-severity issues, fixed in 5.0.10 and 5.0.11, let deeply nested brace groups exhaust the native stack. Roughly 3,100 levels of `{{{...a,b...}}}`, about 6 KB of input, crashed the process: once through the recursive comma splitter and once through the expander itself. The medium-severity third, fixed in 5.0.12, is the bash `{a},b}` quirk. The parser handles it by rewriting the string and rescanning once per trailing `}`, so the cost grows quadratically, and 128 KB of input blocked the event loop for 27 seconds. 5.0.12 makes the comma splitter iterative and caps nesting depth and rewrite passes at 1,000 each. Input past either cap is kept as plain text rather than throwing, the same way the existing `max` and `maxLength` limits work. **No running Bambuddy install was exposed, and none of the issues was reachable at build time either.** `brace-expansion` is a development-only transitive dependency, pulled in by `eslint` through `minimatch`, and it is not in the shipped image. The only patterns it ever expands are the fixed globs in `frontend/eslint.config.js`. Before the bump, the output of 5.0.9 and 5.0.12 was compared on every glob in that file plus ranges, nested sets, escaped braces and the `{a},b}` quirk, and it was identical. The same comparison confirmed the fixes: 5,000 levels of nesting overflow the stack on 5.0.9 and expand cleanly on 5.0.12, and a 20,000-brace rewrite input drops from 3 seconds to 13 ms. The existing `overrides` pin in `frontend/package.json` moves from `^5.0.9` to `^5.0.12`, because `minimatch` still asks for only `^5.0.2`. The lockfile changes in one entry only, `balanced-match` is untouched, nothing is added or removed, and the shipped bundle does not change. Verified with eslint, typecheck, the production build and the full frontend suite (4022 tests across 297 files). `npm audit` reports zero vulnerabilities with and without dev dependencies. ## [1.2.5.4] - 2026-08-29