mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +02:00
4e83751f718bd7ef0305de4b520ddf012ce1066c
973
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d56b48c499 |
feat(auth): connected apps - sign in to external applications with Bambuddy
Minimal OAuth 2.0 authorization-code flow with PKCE (S256): admins register an app with one exact callback URL (Settings > API Keys > Connected Apps); /connect/authorize asks for consent once and returns a single-use, 60 s code bound to app, callback and challenge; POST /api/v1/connect/token swaps it, with the client secret, for the user's identity and permissions. Codes and secrets stored hashed, exchanges rate-limited per client and IP, no redirect before the callback is validated, API keys cannot authorize, refused while auth is disabled. i18n for all 15 locales. ----- fix(db): upgrading from 0.2.4.0 or older no longer crashes at startup The #2974 failure-reason conversion ran before the #1378 migration that adds print_log_entries.failure_reason, so older databases stopped with "no such column: failure_reason". It now skips a table without the column, only runs where a legacy label exists, and on SQLite rebuilds archive_fts first, since archives created before that index existed trip "database disk image is malformed" when updated. |
||
|
|
7c16079ffa |
feat(queue): let a batch record the external order it fulfils
POST /queue/batches accepts external_source + external_ref; both are returned on every batch and filterable on GET /queue/batches. The pair is unique (index uq_print_batches_external), so a retried create answers 409 instead of queueing the same order twice. Migration covers SQLite and PostgreSQL. |
||
|
|
86bd9e1bd4 |
fix(file-manager): harden the merged previews - PDF.js legacy build, server PDF thumbnails, STEP progress (issue #2976)
Follow-ups after merging PR #3128 (with the #2990 preview work): - PDF preview failed on every browser without Map.prototype.getOrInsertComputed (Chrome < 145, Firefox < 144, older Safari): "This file cannot be previewed" for any PDF. Load pdf.js's legacy build, which bundles the polyfills for page and worker, and bundle the worker through Vite (?worker&url) so build.target lowers its class static block for Safari 16.0-16.3. The browser-baseline check only scanned .js output and never saw the copied .mjs worker; it scans .mjs too. - PDF grid thumbnails only existed after someone opened the preview. Page one is now rendered server-side with PDFium (pypdfium2, new dependency, prebuilt wheels for every shipped platform) on upload, ZIP extraction and external-folder scans; Generate Thumbnails backfills PDFs as well. Renders are serialised behind a lock (PDFium is not thread-safe), run off the event loop, and scale from the page size so a huge MediaBox cannot allocate a huge bitmap. Unreadable PDFs land without a thumbnail and keep the browser fallback. - A large STEP file takes over a minute to mesh in the browser and showed only a spinner. STEP loads now show "Converting STEP model... N s" and a note that large files can take a minute or more, in all 15 locales. - Drop the two occt-import-js "externalized for browser compatibility" build warnings (path/crypto are only required in its Node branch); any other externalization still shows. |
||
|
|
12dddada0a | File Manager: external link, notes and photos on library files (#3128) | ||
|
|
6e8d543d2a |
fix(ams): stop showing the humidity drop index as a percentage (issue #3140)
Bambu sends two humidity fields that are not the same quantity. humidity_raw is relative humidity in percent; humidity is a 1-5 drop index, and it runs the other way -- OpenBambuAPI's push_info sample pairs "humidity:30%" with "humidity_idx:4", so a high index means dry where a high percentage means wet. Four call sites used the index whenever no percentage arrived. A unit sending only the index therefore rendered as "2%" in the green band while being the second-wettest of the five steps, charted an average of index values as a percentage, and sat under every humidity threshold forever, since no index can reach one -- the alarm and auto-drying could not fire for such a unit at all. - utils/ams_humidity: one leaf helper, a percentage or None. The index is never converted; None is what every caller already handles. - routes/printers, printer_manager, print_scheduler, main, bambu_mqtt: all five readings go through it, so the card, the websocket, the chart, the alarm and auto-drying cannot answer differently. - main: a unit that reports the index and no usable percentage says so once per unit in the log, with its firmware versions requested. No supported printer is known to do this, and "known" is doing work there -- the alternative is a card that goes blank with no trace. Three faults found while checking what else those paths touched: - main: humidity_raw=float(x) if x else None stored NULL for a numeric 0% while writing 0.0 to humidity on the same row. - main: that same expression was unguarded, unlike the parse above it, so a non-numeric humidity_raw raised inside record_ams_history and aborted the pass for every printer, not just the one that sent it. - routes/ams_history: the averages were tested for truthiness, so a window averaging exactly 0 reported no average while the min and max beside it reported 0.0. An affected unit now reports no humidity rather than a number that means the opposite: the indicator is hidden, the chart leaves a gap, the alarm and auto-drying skip the unit. Temperature is untouched. Auto-drying's outcome is unchanged either way -- an index could never cross the threshold -- so only the intent moves. No supported printer is known to be affected; the report came from an install running X1Plus, which Bambuddy does not support. Verified against 7927 recorded samples from seven AMS units including an AMS-HT: not one used the fallback. Two percentages that did fall through to the index no longer do -- a reading with a decimal point, and "38.0", which int() rejected. |
||
|
|
58ea7a360d |
refactor(models): break the schema cycle that backup and restore sort through
print_archives.library_file_id -> library_files.folder_id -> library_folders.archive_id -> print_archives. Three nullable SET NULL links, each reasonable alone, that together made a loop metadata.sorted_tables could not sort: it dropped those edges, warned on every backup and every restore, and could return an order placing a child before its parent -- which once imported library_files ahead of library_folders and killed a restore on a ForeignKeyViolation. The restore no longer depends on that order (it strips every foreign key before importing and adds them back after), but the backup export sorts the same way, and the warning ends with "may raise an error in a future release" -- which would break backup and restore on one upgrade. Marking one edge use_alter removes it from the sort graph, not from the database: PostgreSQL emits it as ALTER TABLE ADD CONSTRAINT, as it already did for every constraint on these three tables, and SQLite inlines it into CREATE TABLE, so ON DELETE SET NULL holds on both. Verified against PostgreSQL 16 and SQLite. |
||
|
|
0eb083b32e |
fix(slicer): read the plate model once per part for the post-slice thumbnail (issue #3135)
The post-slice thumbnail loaded the sliced 3MF with trimesh, whose reader mishandles the Bambu Studio / OrcaSlicer layout: for every component that references a file in 3D/Objects/ it re-parses that file and appends all of its meshes again. N copies of a part came back as N^2 copies of its triangles, and each component carried every other part of the same file. 25 bins of 10k faces loaded as 6.4M faces; the render took 54 s and 8.4 GB on the event loop, and the server was OOM-killed. Parse the 3MF directly (lxml, entities/DTD/network off, streamed and freed element by element), walk objects, components and build items (p:path on either) with their transforms, and keep each mesh once. Decimate per unique mesh to its share of a face budget before placing instances, flip winding on mirrored placements, and skip the thumbnail above a face ceiling checked both on what decimation can reach and on what it delivered. Both slice routes run the render in a thread. The renderer uses matplotlib's Figure/Agg API instead of pyplot, whose process-global figure state let a threaded plate render and stl_thumbnail's event-loop render lay out and close each other's figures. |
||
|
|
89d94796ee |
fix(queue): keep the filament override when a model job moves to one printer (issue #3133)
Switching an "Any P2S" job to a specific P2S cleared its filament override: "Specific Printer" empties the target model and the reset effect counted that as a model change. Printer mode also matched trays against the 3MF's colours, never sent the override, and left the old one on the row. The reset now compares against the last model actually targeted, so the switch keeps the override while a real model or plate change still clears it. Printer-mode tray matching (single, per-plate, multi-printer and the selector's per-printer editor) runs against the requirements with the overrides applied, mirroring the scheduler's _apply_filament_overrides; an entry naming the slot's own filament is not a swap and keeps its tray_info_idx. Printer-mode submits carry the user's overrides, and the create endpoint stores them for a printer-targeted job, so a dispatch-time recompute of an unresolved mapping looks for the same filament. Saving re-attaches the tray_info_idx an unchanged entry already had, so a virtual printer's force-colour PLA-variant pin (#2650) survives an edit in either assignment mode. The printer card's compatibility filter skips printer-targeted jobs: it mirrors the model scheduler, and hiding a job on filament would hide it from the printer it is going to run on. |
||
|
|
f98381f3d1 |
fix(queue): send the copy count for a cross-model print (issue #3101)
Selecting sliced files for two printer models and asking for 25 copies queued one item. The queue emptied as soon as it dispatched and the Batches tab stayed empty, because no batch is created at quantity 1. A multi-plate file moves the run count off the modal's Quantity field onto a stepper beside each plate (#342), hiding the field. The cross-model submit (#671) posts that field, which in this combination nothing can set, so it stayed at its initial 1. The modal read "19 runs in total" above a button that queued one. Per-plate steppers do not fit a cross-model job: its plate is chosen per candidate, in the alternatives list, so there is one number to give. Exclude cross-model from the per-plate mode and the global field comes back. Drop the plate selector in that mode too. Its choice never reached the request; it only keyed the filament-requirements query, so picking plate 3 for a candidate while plate 1 stayed ticked above produced overrides computed from a plate the job would not print. That query now follows the primary file's own dropdown. Dispatch needed nothing -- it already gives each copy its own candidate rows -- but naming did. A cross-model job carries neither archive_id nor library_file_id, because the candidates are the files, so both branches that name a batch missed and every such order would have read "Batch" in the tab the reporter went looking in. Name it after the first candidate. The existing cross-model tests all mock a single-plate file, which is why the pair was never covered; the multi-plate case is added. |
||
|
|
ebc72e1d41 |
fix(archives): keep the project name when the wrong-plate guard rejects a 3MF (issue #3126)
Bambu Studio files a sliced print on the X2D's internal eMMC, which FTPS does not serve. The bounded probe found a same-named file on the card -- an earlier slice of the same project, plate 4, against a running plate 1 -- and #1204's guard correctly refused it rather than archive another plate's thumbnail, filament and cost. It then blanked subtask_name because swap_plate_suffix returned None. But None also means "this name carries no plate suffix", and such a name holds no stale plate number to be wrong about. The project name was dropped, the row fell through to the gcode_file path, and the archive was titled plate_1. Keep the name for the title only. subtask_name itself stays disowned, because it is what every lookup here is built from and it keys _active_prints, where the cover endpoint's own download of that same name would find this archive and hand the contradicted file to _recover_fallback_archive -- which checks a candidate is a readable 3MF and never which plate it holds. A corrected name is still registered: that one points at the plate actually running. Also name the X2D alongside H2-series and P2S in the Archives banner, the connection diagnostic and the storage-verdict docs -- it stores slicer sends the same way, and an X2D owner was told the explanation did not apply. |
||
|
|
036f0a688f |
Merge pull request #2845 from pascalheidmann/refactor/modular-import
(Refactor): modularize import ("Makerworld tab")
|
||
|
|
0db028f9e6 |
fix(inventory): one structured 409 for a tag another spool holds (issue #3110)
The two tag-link routes answered the same conflict differently. The
built-in one said "Tag UID already linked to another active spool" and
named nobody -- while holding the conflicting spool row it had just
loaded -- and Spoolman mode named the spool inside a different English
sentence. Neither was machine-readable, so a client had to parse prose
to learn which spool to look at, and could only do it in one mode.
Both now raise one shared constructor: code tag_already_linked, the
holder's id, and which identifier collided. That is the detail shape
insufficient_filament and printer_connection_failed already use, so
ApiError parses it with no frontend change.
Two active spools can carry one tag -- no unique index on either
column, no conflict check on PATCH /spools/{id}, and /spools/bulk
copies one payload including the tag into every row it creates -- and
the lookup read that with scalar_one_or_none(), which raises on two
rows. The exception escaped into the auth middleware's fail-closed
handler, so the caller was told the authentication service was
unavailable. Both lookups are now ordered and take the first row, as
get_spool_by_tag earlier in the same file always has.
Naming the lowest id means the Spoolman scan reads every row where it
used to stop at its first match, so it now reads extra.tag defensively:
that field is edited outside Bambuddy, and a single null further down
the list would otherwise take the request down in place of the 409.
The kiosk reads the new code: a refused link showed a flat "Failed to
assign spool" and now names the spool holding the tag, reusing the
inventory.tagAlreadyLinked key that no code referenced.
|
||
|
|
905bda4f3f |
fix(ams): read the firmware presence bit, not the tray state (issue #3084)
Swapping a Bambu spool for one the AMS cannot read left Assign Spool publishing no ams_filament_setting at all. The printer kept showing "?" on its screen and in the slicer, and only Configure, which publishes unconditionally, put anything there. Four places asked the tray's `state` field whether a spool was in the slot. It cannot answer that. An AMS-HT reports its LOADED tray as 9 rather than 11, because it does not feed into a shared buffer the way a 4-slot AMS does -- the merge has skipped its own state heuristic for HT units since #2594 for exactly this reason. And the field is partly our own writing: apply_tray_exist_bits stamps state=9 on every slot whose tray_exist_bits bit is 0, and when the bit comes back it refreshes only the `exists` annotation beside it. Either way the slot sits at exists=True, state=9 until something configures it. That 9 also kept the deferred-configuration replay from firing -- its own "has a spool appeared" test was the same heuristic -- which is the deadlock #1322 removed from the assign path, still in place one step further along. And it is what deleted the assignments in #3100: with the replay never firing, the row kept the empty fingerprint it was stored with, and the first tray report naming a filament was read as a swap. All four now read tray_exist_bits first, which is the mask firmware answers this question with and the one the printer card has drawn its "?" from since #2527. The bit is allowed to overrule an "empty" state and nothing else: a bit reading empty deliberately does not start suppressing pushes that go out today, because the cost of computing a bit position wrong is a slot that silently stops configuring, against a saving of one message firmware would have dropped. A blank tray report from a slot the bit calls occupied no longer unlinks anything, off a print as well as during one, in both inventory modes -- Spoolman's parse_ams_tray calls a tray with no type empty, so a tag-less spool assigned through the UI had its row deleted by the first idle push after it went in. A filament the AMS cannot identify is not a filament that was removed. |
||
|
|
4a85e033c0 |
fix(finance): show the currency the install is configured for (issue #3123)
The Finance page was the only surface in Bambuddy that read its currency from a data row rather than the `currency` setting, and it fell back to EUR where every other page falls back to USD. One variable drives every amount on that page, so the personal balance, the cost-center budgets and the whole transaction list were wrong together on any install not set to euros. It now takes the configured currency from /settings/ui-flags, which is readable by anyone who can see Finance -- /settings needs SETTINGS_READ, which a cost_centers:read_own user does not have. The backend was the other half. Of the four places that settle on a currency, three wrote a hardcoded "EUR": the wallet the API mints on demand, the wallet a print charge mints when none exists, and the balance returned for a user with no wallet row at all. All four now go through one resolver, which lives beside the rest of the balance logic. The wallet's currency column is removed outright rather than merely ignored. An install has one currency and nothing here converts between them, so a per-wallet copy could only ever drift from the setting -- and a column nothing reads is a trap for whoever finds it next. A startup migration drops it on both SQLite and PostgreSQL, after the raw CREATE TABLE that would otherwise re-add it on an install whose finance tables predate the ORM. SQLite builds older than 3.35 have no DROP COLUMN and keep it, harmlessly, since it has a default and no reader. Saving settings now invalidates the ui-flags query too. Nothing did, so a changed currency sat behind that query's staleTime before showing up. The sponsor prompt's own EUR fallback is now USD, matching AppSettings. |
||
|
|
70b42d1c8d |
fix(library): stop reporting success for a bulk add that queued nothing (issue #3112)
POST /library/files/add-to-queue reported every per-file rejection in an errors array and returned 200 regardless. A caller that checks the status code saw a successful request, no visible failure, and no queue item. That is a 400 now when nothing at all was added, with the same reasons in the body. A call that created some items still succeeds, because it did. The items it created were aimed at nothing. The route always wrote printer_id=None with no target_model, and the scheduler dispatches on one or the other -- so those rows matched neither branch and could never be picked up by anything. They sat in Unassigned until someone opened each one by hand. The request takes an optional printer_id or target_model for the batch, and with neither it aims each file at the model its own G-code declares. Only when a printer of that model is active: owning no H2D is the user's situation rather than their mistake, so the file still queues as the unassigned row it has always been, rather than gaining a target nothing can answer. Three gates POST /queue/ has applied for a while now apply here too, because an item reaching the scheduler through this route has to be as printable as one reaching it through that one: the cross-model check that stops a file sliced for one printer being dispatched to another (#2578), the filename check that would otherwise surface as a failed upload hours later (#1540), and the filament requirements the scheduler matches before handing a model-based item to hardware. Nothing inside Bambuddy calls this endpoint -- the Library's own Print action goes through the queue API with a printer already chosen -- which is how it came to drift this far from it. ----- fix(library): scope add-to-queue file reads to the caller The bulk add resolved its files by raw id. Every other read in this module goes through the ownership gate, and so does the single-item queue path; this one did not. Invisible rows are dropped before the loop, so they report as the plain "File not found" an unknown id already gets. |
||
|
|
3a5f802cdc |
fix(diagnostics): read the subnet the host is actually on (issue #3092)
The Network subnet check told the reporter that 192.168.98.170 and 192.168.96.9 were on different networks and to go configure routing between them. They are four hundred addresses apart inside one 192.168.96.0/22 LAN. An IPv4 address does not carry its prefix, and the check supplied /24 for both sides. That is the most common LAN and not the only one, and the guess is wrong in both directions: it splits a /22 and it merges a /25. Read the prefix off the interface that owns the address instead. find_local_ipv4_network() enumerates every interface, including the ones EXCLUDED_INTERFACE_PREFIXES hides. That list keeps docker0 and friends out of the Virtual Printer's bind dropdown; here the caller is asking about an address the kernel has already picked as a route source, and answering "unknown" because it sits on a bridge would be a worse answer than the truth. When nothing claims the address the check skips, which is what it always did with no host IP at all -- it must not assert a split it cannot see. The same check chose which of Bambuddy's own addresses to compare by probing a route toward 10.255.255.255, which on a multi-homed host is not the interface the printer is on. It asks for the route toward the printer now. On a two-NIC dev box that alone was warning about a printer sitting on the second card's own subnet. The probe takes IPv4 literals only. connect() on a name would resolve it on the event loop, and _same_subnet rejects names anyway, so nothing is lost. Resolving the prefix shells out to `ip -j addr show`, so it moves off the loop too. ----- fix(diagnostics): name the container engine instead of asking about Docker (issue #3092) "Not running in Docker - not applicable", said to a Bambuddy inside a Podman container. It reads as "you are on bare metal", and it sent the reporter looking for his problem somewhere else. Podman runs Bambuddy in exactly the two shapes Docker does, and the shape is the thing that breaks printer discovery and the Virtual Printer. detect_container_runtime() names the engine -- Docker, Podman, Kubernetes, containerd, LXC, or a container it cannot place -- and the check became Container network mode. is_running_in_docker() is deliberately left alone rather than rewritten on top of it. Three callers key real behaviour off that flag, and one of them switches the Add Printer flow from SSDP to subnet scanning. SSDP works for a host-networked Podman container, so answering True there would take a working feature away to fix a sentence. Widening it is a separate decision from naming the engine, so it is made separately. Mode detection keeps the original signal first, which also makes the Docker path incapable of regressing: a Docker host always has a docker0, so a container that sees one shares its namespace, and the new rules can only turn a warning into a pass. That signal says nothing about Podman, which creates no such interface on a host running no bridge containers -- which is how host networking came to be reported as bridge. The general form of the same idea answers for Podman: an interface whose iflink equals its ifindex was created in this namespace, and a NAT-networked container only ever receives one end of a veth pair. tun/tap is skipped, because a container may run its own WireGuard and that tun is native to a namespace it is not evidence of. The interface also has to be the one the kernel just named -- sysfs is namespace-tagged but a bind-mounted host /sys is not, and reading a colliding name's numbers would be reading another namespace's answer. What is still unreadable now says so and suggests host networking if discovery is failing, rather than guessing bridge and telling a healthy install to recreate itself. An LXC or LXD system container is named and told the question does not apply: it is on the LAN like a small virtual machine, so there is no network mode to recommend -- and its subnet check still runs. An engine we cannot name is a sentinel the frontend localizes, not a word interpolated into thirteen other languages. The support bundle carries the engine name beside the Docker flag, so the next report of this shape is answerable from the bundle. |
||
|
|
e4a9ef4550 |
fix(spoolbuddy): show the colour name the rest of Bambuddy shows (issue #3090)
SpoolBuddy said "Unknown color" under a correctly-coloured swatch for spools the inventory page names without trouble. The name was never in the spool record. Bambu's RFID tags frequently carry no readable colour name -- some carry an internal code instead -- so Bambuddy has always resolved the swatch's own hex against the colour catalog, and the kiosk was rendering the empty column. Exactly one SpoolBuddy file already did it right, which is what marks this as an inconsistency rather than a kiosk simplification. Route every SpoolBuddy colour-name display through resolveSpoolColorName, which also stops the spools that do carry a code from showing "A06-D0" at the user. The write-tag edit form keeps the raw stored value on purpose: offering a derived name for editing invites the user to save it as though they had typed it. Spoolman has no colour-name field at all, so _map_spoolman_spool puts the spool's subtype there and sets color_name_is_synthesized. That flag now travels on the tag-matched broadcast, and resolveSpoolColorName takes a third argument to honour it -- a synthesised name loses to the catalog and survives only as a last resort. Spoolman installs were reading "Silk+" as a colour on the Inventory page and the AMS hover card too, so those call sites pass the flag as well. Searching by a colour you can read on screen now finds it, in the kiosk and in Bambuddy: the shared inventory filter matches the resolved name as well as the stored one. That makes the filter depend on the catalog, which loads asynchronously, so the three memoised call sites take its version as a dependency -- without that, a query typed before the catalog arrives keeps its empty result and reproduces the very symptom being fixed. The fallback label was hardcoded English in components that already import useTranslation; it is now spoolbuddy.spool.unknownColor in all 14 |
||
|
|
2c7c97c130 |
fix(jog): send the nozzle-bed gap the API promises on every model (issue #1334)
POST /printers/{id}/bed-jog takes a signed nozzle-bed gap, documented since it
was written: positive asks for more room between the nozzle and the plate. On
an A1 it did the opposite. The reporter sent distance=5 for clearance and
watched the toolhead come down.
The sign had been flipped on A1 models since the original report on this issue,
where an A1 Mini owner clicked an arrow labelled "move the plate up" and watched
the nozzle dive. That is a labelling problem -- a bed-slinger's plate does not
move in Z at all, so closing the gap shows up as the toolhead descending -- and
it was solved in the transport layer, which turned a parameter documented as
model-independent into one that meant the opposite thing on part of the fleet.
Z is the nozzle-to-bed distance on every Bambu model, by definition of the
coordinate system rather than by convention: G1 Z+ opens the gap whether the bed
drops away from a fixed nozzle (X1/P1/H2, whose end G-code parks with
G1 Z{max_layer_z + 100}) or the nozzle rises off a fixed bed (A1/A2L). The
finish-photo plate restore already relies on exactly that and carries no model
branch. So distance goes onto the wire unchanged and one call means one physical
outcome everywhere: positive is the safe direction on every printer.
Which way an arrow points is a different question, about the machine in front of
the user rather than about G-code, so the printer card answers it and asks for
the gap it wants. The buttons move what you would expect them to move, exactly
as before; on a bed-slinger they now say toolhead rather than plate.
The A2L never had the old fix. It slings its bed the same way the A1 does, but
the inversion listed the A1 names and the A2L was not among them, so its up
arrow has been sending the toolhead at the plate for as long as the machine has
been supported. The new classifier also covers the alternate internal codes
A04 / A11 / A12, which LINEAR_RAIL_MODELS and SINGLE_NOZZLE_FLOW_MODELS both
carry and the old gate did not.
is_bed_slinger is gone from the backend rather than widened: with the route
model-independent it had no caller, and a kinematics helper sitting unused in
the service layer invites the next person to assume the backend handles
direction. It does not, deliberately.
Separately, the soft-endstop comments on both jog routes claimed the firmware
clamps a bare move at the travel limit. It does not, and #2579 measured that:
an H2D at its Z limit ran straight past a clean G91/G1 Z-1.00/G90, while its own
touchscreen refuses the identical move. What #2579 removed was M211 S0, which
disabled the limits globally and took the touchscreen's protection with them.
The jog popover has warned about this correctly the whole time; only the code
comments disagreed with it.
|
||
|
|
a4cfbd4212 |
fix(archives): come back for a 3MF whose transfer ran out of time (issue #3063)
The reporter's P1S had the sliced file on its card and was serving it. The 19MB transfer just did not finish inside the budget while the printer was also running its camera, its status messages and the upload of the job itself. Bambuddy wrote an empty fallback archive and never looked again -- then downloaded that same file successfully three times over the next two minutes and discarded every copy, because the only code that would have attached one had already run. The recovery machinery was there. It was armed for exactly one give-up, the FTPS cool-off, on the grounds that the three storage verdicts are settled: a job on internal eMMC never appears at any FTPS path, and sweeping for it again is what where the file is demonstrably still on the card. The sweep already had the signal and never used it. A file that is genuinely not there is answered with 550, which surfaces as FileNotOnPrinterError and is caught by name; a timeout returns falsy instead. So "the printer says no such file" and "we never got a straight answer" are distinguishable without guessing, and only the second schedules anything. Not scheduled either for a 3MF that downloaded fine and turned out to be another plate's. Recovery checks that a candidate is a readable 3MF but not which plate it holds, and the names a retry would use are the same stale ones that fetched the contradicted file -- so it would put back exactly what #2957 discards. The ladder follows the cause: a cool-off has to expire, so its first attempt sits past the 300s; nothing has to expire here, and this reporter's file completed 48 seconds after the budget was spent. The archives banner gets its own wording for this, because the old text sends an owner whose card is working to switch on a setting that is already on. It names the Connection Timeout setting instead. |
||
|
|
309e64b8a2 |
fix(ams): resolve a slot's K profile by index when the printer does not file per hotend (issue #3044)
An X2D with two AMS 2 Pro, one per hotend, showed a K value on every slot of the first and nothing on any slot of the second. Configure Slot was worse than blank there: the picker offered no matching profile, the slot read as though nothing were bound, and choosing one changed nothing the user could see. Both symptoms are one rule. A calibration index can mean two different profiles on a dual-nozzle machine -- on the maintainer's H2C, index 16 is the left hotend's black PLA at K=0.018 and 15 is the right's at K=0.020 -- so the index is resolved against the slot's own hotend, and a miss shows nothing rather than the other nozzle's number. That is right whenever the printer files its calibrations per hotend. This one files them per filament: the second AMS's slots point at the same entries as the first, every entry tagged with one extruder, and requiring a match found nothing at all. The hotend now has to appear in the table the printer actually sent before it is used to narrow anything. Where it does not, the index stands on its own, which is what BambuStudio does for this same card -- AMSItem.cpp resolves it through get_pa_k_n_value_by_cali_idx, matching cali_idx and nothing else. Where it does, nothing changes: the H2C case still blanks rather than borrowing, and the other hotend's profiles stay reachable under Other K profiles. The relaxed path still refuses an answer when the candidates disagree on a value. The premise that the table is always numbered per nozzle had been written into three comments and two layers of code; it is corrected where it appears. Alongside it, in the same picker: the K-profile options rendered the hotend suffix twice in the matching group and three times under Other, so every option on a dual-nozzle printer read "... . Left . Left". |
||
|
|
9a837d19a1 |
fix(slicer): strip zero-valued filament-index sentinels, and sanitise the preview slice too (issue #3030)
Bambu Studio writes 0 into wall_filament, sparse_infill_filament and solid_infill_filament to mean "use whichever filament the object is set to". Bambu Studio and OrcaSlicer 2.4 define these min 0 and accept it; OrcaSlicer 2.3 and earlier used the 1-based scheme (min 1, default 1) and reject it with "0 not in range [1.000000,...]". Sidecar images are version-tagged, so an install can be pinned to one of those builds. Same shape as the -1 inherit markers from #1201 with a different marker, so the allowlist becomes a key-to-marker map rather than one global constant. The buckets must not bleed: a -1 on a filament index is a real value, and a 0 on a raft field is a setting the user chose. The key is removed rather than rewritten, which is what makes it safe on every build. The CLI then uses its own default: 0 where 0 was legal (unchanged), 1 on the older builds, which is what "the active filament" means under that scheme. The preview slice never ran the sanitiser at all, so a file that sliced fine could still fail its automatic plate preview and fall back to the painted-face heuristic. It matters more there than in a real slice: the preview runs on the file's own embedded settings, so there is no --load-settings pass that could supply a replacement for a field the range validator has already rejected. That also explains the reported "same error on a later attempt of an unchanged file" without any second copy of the keys -- the validator that emits it reads the merged global config, which per-object model_settings.config overrides never reach. The sanitiser moves to utils/threemf_tools so the service can use it without importing a route module, and both preview callers pick it up from one place. Drops _strip_3mf_embedded_settings and its constant, which have had no callers since the strip-everything experiment was reverted. |
||
|
|
b9bd312826 |
fix(slicer): keep protocol-handler download tokens valid for their whole TTL (issue #3029)
The Slice and Open in Slicer actions mint a short-lived token and put it in the URL, because a protocol handler cannot carry an Authorization header. That token was spent by the first request to reach the endpoint, which made the handoff depend on the slicer fetching the URL exactly once. Nothing guarantees that: Bambu Studio's downloader retries three times after a failed attempt, transfers get resumed, on-access scanners fetch. The first request won and the slicer was handed a 403. verify_slicer_download_token takes a keyword-only single_use flag. The default still consumes via DELETE...RETURNING; single_use=False verifies with a SELECT and leaves the row for the rest of its five-minute TTL. The stored row is the same either way, so the endpoint decides, not the mint. The three protocol-handler downloads pass single_use=False: a library file, an archive's sliced 3MF, an archive's source 3MF. Resource binding and expiry are untouched. The two browser downloads keep consuming, because what they hand over is itself consumed -- the prepared printer bundle is deleted the moment it has been streamed. Also: add "/source-dl/" to PUBLIC_API_PATTERNS. Those patterns match by substring and the source 3MF route's segment is source-dl, which does not contain "/dl/", so with auth enabled the middleware rejected the slicer's header-less request before the route's token check ran. Open source 3MF in slicer could never work on an install with authentication on. |
||
|
|
816f073a9e |
fix(auth): decouple media routes from the camera stream token (issue #3025)
Thirteen routes with nothing to do with a camera took the camera stream
token as their credential -- library and archive thumbnails, plate
previews and plate thumbnails, timelapses, print photos, archive QR
codes, project covers, print-log thumbnails, printer covers and
external-link icons. A browser cannot put an Authorization header on an
<img src>, so these need a credential that fits in the URL, and the
camera token was the only one that existed. Minting one costs
camera:view, so a user granted library access to their own files got a
grid of broken images until they were also handed the live camera.
Adds a media token: minted by POST /auth/media-token behind plain
authentication, and identified -- it records the principal the way the
websocket token does rather than being anonymous the way the camera
token is. Each route now gates on the permission and ownership rules of
the resource it serves, through the same _ensure_*_visible helpers its
header-authenticated siblings already use. The three camera routes keep
the camera token, and require_camera_stream_token_if_auth_enabled now
documents that it is for those only.
The media dependencies accept ordinary Authorization / X-API-Key headers
as well as ?token=, delegating that path to the existing checkers, so
API-key scope rules and the per-printer allowlist are unchanged.
Long-lived camera_stream, camwall and overlay tokens are deliberately
not accepted on the media routes -- those are handed to kiosks, walls
and Home Assistant to display video. The cam wall, streaming overlay and
kiosk views use only the three camera routes and are unaffected.
Frontend: withMediaToken alongside withStreamToken, and
useStreamTokenSync fetches a media token for every signed-in user while
asking for a camera token only when the user can mint one, which also
stops the 403 that fired on every page load for everyone else.
Also fixed, same class:
- /printers/{id}/files/plate-thumbnail/{i} is rendered in an <img> but
had a header-only guard, so the file manager's plate thumbnails 401'd
whenever auth was enabled. It now takes a media token too.
- getProjectCoverImageUrl returned a URL ending in ?token=, and the
project edit dialog appended its own ?v= cache-buster after it, so the
second ? landed inside the token value. The version is now a parameter
applied before the token.
Tests: 15 integration tests for the token boundary, permission
enforcement and per-row scoping; 10 frontend tests for the URL split and
the two-query hook. test_cover_image_get_uses_stream_token_gate is
renamed and repointed at the media gate -- what it pins, that the
credential has to fit in a URL, is unchanged.
|
||
|
|
93eeb05264 |
fix(auth): let the sidebar read install flags without settings:read (issue #3023)
cost_centers:read_own exists so a non-admin can see their own wallet, balance and cost-centre spend, and the Finance page honoured it -- typing the URL worked and rendered their balance. The sidebar never offered the entry. It decides whether to show Finance by reading billing_enabled from GET /settings, which requires SETTINGS_READ. A non-admin gets 403 there, so the value arrived undefined, `undefined !== true` held, and the entry was hidden from precisely the users the permission was written for. The permission map and the route guard were both already right; only discovery was broken. Three more fields came from that same 403, and one of them failed the other way up. The Notifications gate tests `=== false`, which undefined never satisfies, so an administrator who switched user notifications off still left the entry showing to the non-admins it governs. Nobody reported that one, and no administrator could have reproduced either: administrators can read /settings. The remaining two were quieter -- the sponsor prompt fell back to EUR whatever the install uses, and the update check ran where it had been turned off. SETTINGS_READ cannot be the price of knowing whether billing is on. It also grants sight of the SMTP, LDAP and MQTT credentials, which is the reason /settings/ui-preferences exists at all. So: a second endpoint, GET /settings/ui-flags, carrying those four fields and asking only that the caller be signed in, via the existing require_auth_if_enabled. Layout drops its /settings query altogether, which closes the class rather than the two instances that happened to be visible. Deliberately not four more fields on /ui-preferences. That endpoint is served to anyone at all on the recorded grounds that its contents are "public defaults that ship with the app" (test_route_auth_coverage.py), and its field set is pinned by a test written to make anyone adding to it stop and think. These fields are not defaults -- they say how this deployment is configured -- so they get their own endpoint at their own trust level instead of stretching that charter to fit them. require_auth_if_enabled also keeps the auth-disabled case that /ui-preferences was ungated for: "works when there is no auth" and "readable by anyone" are different statements, and conflating them is what put a settings read in front of a permission that never needed one. Twelve tests. Backend pins that the operator can read the flags, that the same operator still gets 403 from /settings, that an anonymous caller is refused when auth is on, that it answers when auth is off, the exact field set, that no credential ever appears, and that the public endpoint did not quietly gain these fields. Frontend pins Finance visible for cost_centers:read_own with /settings returning 403, and Notifications hidden when the flag is off -- each waiting on a positive signal before asserting an absence, so the negative cases cannot pass before the query resolves. Reported by @lonix, who traced it to the queryKey and the route gate. |
||
|
|
6564c74071 |
fix(archives): report a refused FTPS handshake as the printer, not the slicer (issue #2780)
The Archives banner picks its wording from a priority list of the causes it knows. REASON_FTPS_COOLOFF was added by #2957 and never put in that list, so an install whose empty archives all came from a printer refusing the TLS handshake matched nothing, got reason: null, and fell to the original wording: the slicer did not leave the .gcode.3mf on the card, switch on "Store sent files on external storage", here is installation step 4. Every clause of that is wrong for this cause. The slicer did write the file -- reason he read the whole thing as Bambuddy being broken. The setting was already on. And there is nothing on his side to change: the printer's file service answered port 990 with something that is not TLS, so no lookup ever ran and where the file went was never tested. It is #2899's mistake -- an error message describing a cause that was ruled out before it was printed -- in a surface that did not get that pass. The slug now leads the list rather than joining the end of it. The other three describe an install working as configured and each ends in something the operator can change; this one reports a fault nobody can yet explain, which is both the more urgent thing to say and the thing that produces a useful report. The banner also dismisses one-shot into localStorage, so a reason ranked below another is not deferred to next time -- it is never shown to that user again. Ranking it first cannot bury a permanent cause in exchange: a successful recovery clears the row's markers (#2957), so a row still carrying this slug is one whose retry failed too, days after the print. New wording in all fourteen languages says the printer refused the connection, that this is not a slicer setting and not something the operator did, that Bambuddy comes back for the file when the five-minute pause clears so a brief episode fills itself in, and that a card still empty means the refusal outlasted the retry. It links to the handshake entry in the troubleshooting guide instead of to the installation guide. The client's getNo3MFWarning type still declared the old three-slug union, which made all three new comparisons provably dead -- caught by tsc, not by any test. Four tests. One pins the slug reaching the banner, one pins it outranking the three settled causes, one pins those three keeping their order behind it, and one asserts the rendered wording carries no slicer advice at all. Also corrects the wiki page these reports are pointed at. It said to power-cycle the printer; the reporter who prompted that advice power-cycled both of his and the failure continued unchanged, and bambu_ftp.py has carried the retraction in a comment since. The page now states what was actually measured -- that a version mismatch reports itself differently, that every printer probed refuses TLS 1.3 and completes on 1.2 so there is no version to fall back from, and that three P2S units failed while three more on the same switch never did -- says plainly that the trigger is unknown, and names the one cleartext-probe line worth collecting. |
||
|
|
f3b1c59169 |
fix(orca-cloud): close the HTTP client when an authenticated build fails
OrcaCloudService owns an httpx client from construction, and every path in _build_authenticated_service after that point can raise: no stored refresh token, a rejected refresh, an unreachable Orca, and the token-rotation write. On success the caller closes the client. On failure nobody is ever handed it, so all four paths leaked one into the connection pool. That went unnoticed while the only callers were routes, where the trigger is a person retrying a broken sign-in a handful of times. It stopped being harmless in |
||
|
|
9434875fa1 |
fix(ams): resolve a custom filament's own id from every preset source (issue #3003)
A custom filament profile reaches an AMS slot as itself through exactly one field, tray_info_idx, and every source we can read that id from was reading it from the wrong place or not reading it at all. Bambu Cloud returns a preset's own filament_id either on the response envelope or inside the preset JSON under `setting`, and only the envelope was read. Presets of the second shape fell through to the base_id branch and reached the slicer as the Bambu filament they inherit from. filament_type next door already handled both spreads; filament_id now does too. Orca Cloud was absent from the resolver entirely. A spool stores the bare profile UUID, which matched no branch and fell through normalize_slicer_filament -- a function that passes anything it does not recognise straight through -- so a 36-character UUID went into the field. Orca profiles carry their own filament_id in the slicer JSON that OrcaProfileDetail already exposes under `setting`, so the lookup is the same one the Bambu branch does. It is best-effort: no pairing, a dead token or a missing orca_cloud:auth permission degrades to the fallback rather than failing the assignment, and it passes clear_on_auth_failure=False because a background caller cannot tell a real revocation from a lost refresh-rotation race. configure_ams_slot sent the cloud setting_id as tray_info_idx when it found no real filament id. That field is 8 characters on the printer -- exactly the width of a local preset id, less than half a cloud one. Measured on the reporter's A1: sent PFUS9ddc938fe3ab8f, the tray read back PFUS9DDC, acknowledged as a success. The slot then resolved to nothing, so the slicer showed Generic anyway and the calibration table, keyed by the same field, lost the slot. It now falls back to the slot's existing filament id or the generic for the material, and the route's guard was aligned with the resolver's so both refuse the same four shapes from one shared definition. This reverses the contract #1053 pinned. Six tests asserted that the PFUS belonged in tray_info_idx; the A1 capture shows it never worked, so they were rewritten with the measurement in their docstrings. Verified against 874 AMS trays across twelve models in the support archive: 92 already carry a custom "P" + 7 hex filament id, which is what confirms the mechanism works and this is a lookup failure rather than a platform limit. No tray on any model carries a setting_id, so a profile with no filament_id of its own still cannot be told apart from its base. |
||
|
|
2e405afcd1 |
Decide whether a 3MF is sliced by looking inside it (issue #2993)
An archive that showed the green GCODE badge could re-import into the File Manager as a source-only project with no Print button, seemingly at random. Nothing was ever lost from the file. The download serves the stored bytes verbatim and the G-code was still in the zip; the two sides simply asked different questions. Archives looked inside the file. The library looked at the filename. So a sliced 3MF stored as Foo.3mf rather than Foo.gcode.3mf earned the badge and lost the Print button, and which one you got depended on how the print had reached the printer -- a slicer's LAN send names it .gcode.3mf, a per-plate export or a cloud-dispatched print does not. Both sides now ask one shared predicate about the zip itself, and every route into the library classifies on content. Only the central directory is read, and only when the name has not already settled it, so ingest costs nothing extra -- the external scan opens each 3MF for its thumbnail regardless. Rows already stored are re-checked once, internal ones only: an external row points at a mount that may be slow or absent, and startup is the worst place to discover that. The Slice action moves with it. Its refusal to slice an output was as name-bound as the Print gate, and without that a file that correctly gained a Print button would have offered to re-slice its own G-code. |
||
|
|
7c10412f99 |
Refuse a same-named 3MF that contradicts the running print (issue #2957)
When a print's own 3MF cannot be fetched the usage tracker borrows one from the library or a previous archive, matching on the filename stem. That is far weaker evidence than it looks: Bambu Studio writes the printer-side filename from the project's Title metadata, so every plate of a project reaches the printer under one name however the file was renamed on disk. The reporter's single-filament job was handed a previous archive's three-filament plate. Three spools were debited for material that was never extruded, and nothing on the archive said the numbers were someone else's. A candidate is now rejected when it positively contradicts the print - a different plate, or a filament count the slicer's ams_mapping disagrees with - and the accepted one is logged with both expectations. Only on a contradiction: the plate needs firmware that echoes it and the count needs a print command Bambuddy saw, and refusing everything uncorroborated would retire the fallback recovery this same issue asked for. The count is scoped to one plate or not compared at all. Unscoped, the filament reader collects every <filament> in the file, and that sum against one plate's count would reject every multi-plate library upload on exactly the firmwares that cannot tell us the plate. ----- Give a download the time the file needs, and one printer at a time (issue #2957) ftp_timeout is handed to every download as both the socket inactivity timeout and the whole-transfer deadline, which makes its 30s default a cap on how big a file a printer may serve. The reporter measured one 5.4 MB 3MF at 45s off a worn P1S SD card and 25s off a new one, and a 15.15 MB 3MF at 105s. None of those links were broken - they were slow, which is what the inactivity timeout exists to tell apart. download_to_file already asks for SIZE. It now reports it, and the total deadline follows the file at the 25 KB/s floor _upload_deadline has used since do, so #2572's cap on the executor queue wait is untouched. Capped at 300s for a reason that is not about FTP: on_print_start holds a pooled DB connection across its whole 3MF hunt. Downloads also take turns per printer now. He watched Bambu Studio lose its own connection while Bambuddy pulled a 12 MB 3MF, and a later log caught two Bambuddy downloads of the same file overlapping at print start. The gate is soft - whoever cannot have it within 30s goes anyway, because a print losing its 3MF to queueing is worse than the contention, and a soft gate cannot deadlock. It is also meaningful for the first time. The 90s cap on a multi-path lookup returned while its worker kept walking the remaining paths, still on the printer's socket; that walk is now cancelled and waited out before the printer is handed on. ----- Look in the shared 3MF cache again before each cover retry (issue #2957) The cover endpoint and the print-start archive flow share a cache so whichever fetches the 3MF first hands it to the other (#972). The cover consulted it once on the way in, then retried for up to two and a half minutes without looking again. In the reporter's log the archive flow published the file 42 seconds into that sequence and the cover's third attempt still pulled its own 5,250,969-byte copy, off a printer that was mid-print on the same SD card. A file picked up that way is left alone rather than re-registered under this endpoint's own name or deleted on the way out. It is the archive flow's. |
||
|
|
d4477e9b71 |
Log ffmpeg's error instead of its build banner
ffmpeg opens every run with ~20 lines of version and build banner and prints its diagnosis last, so the stderr[:200] eight of the nine call sites used kept the banner and threw the error away. The reporter's twelve capture failures all read "ffmpeg version 7.1.4 ... configuration: --prefix=/usr --extra-version=", identical on every install; the exit code was the only usable byte. The banner-stripping summariser written for #925 lived private to the camera route. It now lives in backend/app/utils/ffmpeg_output.py and every ffmpeg and ffprobe stderr goes through it. Two things the scattered copies also got wrong: four logged the input URL unmasked, publishing a printer access code or camera password, and four called a bare .decode() on bytes ffmpeg copies stream fragments into. ----- Delete the files a no-3MF archive owns, without taking a printer folder Both delete paths derived the directory from file_path, which such an archive does not have, so they removed nothing and logged it at ERROR under a SECURITY banner. That was true when the archive was an empty row and stopped being true once one could hold a timelapse and finish photos in <archive_dir>/<id>/ and an uploaded source in archive/no_source/<id>/. The two are cleaned up by different means, because <archive_dir>/<id> shares a namespace with the per-printer folders: a normal archive lives at <archive_dir>/<printer_id>/<timestamp>_<name>/, so archive/1 is printer 1's folder and also the directory the shared helper hands archive id 1. Ids come from unrelated sequences, so the first few archives collide with the printers on every install, and an rmtree there takes every print that printer made -- measured on a scratch tree. no_source/<id> is a level deeper under a name no printer id can take and is removed whole; the id-named directory gives up only its photos subdirectory and the video the row records, then goes only if that left it empty. The depth guard moves from one to two for the same reason: a file_path that lost a path component could point the delete at a printer folder, and no archive directory has been one level deep since the first commit. Hard delete had its own copy of these rules, which the helper's docstring says it exists to prevent, and it had diverged -- it skipped the print-log thumbnail cleanup whenever a guard tripped. ----- Stop the RTSPS proxy leaving a handler behind at shutdown asyncio.start_server keeps only a weak reference to the connection callback's task, so a handler still awaiting its forwarders could be collected while pending -- "Task was destroyed but it is pending!", at ERROR with a traceback into camera.py, once every few hundred snapshots. Teardown had the matching gap: server.close() leaves established connections running, so the close waited on a handler that only finishes when the peer drops, and ffmpeg has already been reaped by then. Handlers are held for as long as they run and cancelled at shutdown, which is Server.close_clients() by hand -- that landed in 3.13 and Bambuddy supports 3.10. Both the snapshot path and the streaming endpoint share the shutdown. |
||
|
|
0d21239e18 |
Send AMS tray colours as uppercase hex (issue #2987)
Assigning a spool to an AMS slot unassigned it again seconds later, and
the slot's colour changed at the same time. It presented as Bambu Studio
and Bambuddy fighting over the slot. The reporter's log shows Bambuddy
losing to itself.
P1S firmware 01.10.00.00 reads every lowercase hex letter in an AMS
tray_color as a zero, and hides it completely: the command response
echoes back the value that was sent and reports result "success", so
only the next AMS push says what was really stored. The spool-assign
path sent spool.rgba verbatim and that column stores lowercase. From the
bundle:
sent 09ff00ff -> AMS reports 09000000
sent ff5100ff -> AMS reports 00510000
sent 090000FF -> AMS reports 090000FF
That is the visible colour change, and it is also what deleted the
assignment. The auto-unlink sweep asks whether the slot still matches
the spool assigned to it; the mangled colour no longer did, so the
assignment Bambuddy had made four seconds earlier was removed.
colors_similar('09000000', '09FF00FF') is False, which is the whole of
it.
Re-assigning could not recover, because the Configure Slot dialog seeds
its colour from whatever the printer currently reports. It wrote the
mangled colour back and cemented it, which is the loop the report
describes in its steps 4 and 5.
Colours are now uppercased where the command is assembled rather than in
each of the four routes that configure a slot. A caller that forgets is
exactly how this arrived. Nothing else changes: no padding, no invented
alpha, no six-to-eight widening, and tray_type and tray_sub_brands keep
their case, where it carries meaning -- "PLA Matte" is a product line,
"PLA MATTE" is not.
Two paths deliberately left alone. The developer-mode probe re-sends the
colour the printer itself just reported so that the probe is inert;
uppercasing there would turn it into a write. And the Virtual Printer
forwards the slicer's own command verbatim -- Studio could in principle
hit the same firmware bug, but nothing here evidences that it sends
lowercase, and rewriting a slicer payload inside a transparent proxy is
not a change to make on a hunch.
Two more defects from the same log.
A spool with a brand and no subtype was configured with the string
"None" in its name: the branded branch interpolated spool.subtype
without checking it while the unbranded branch guarded it, so
"Sunlu PLA Matte None" went on the wire and into Studio's display.
And the FTP log is readable again. A 426 whose bytes Bambuddy has
already verified against the printer is how Bambu FTPS normally ends a
transfer, not a fault, so it drops from WARNING to INFO. It fired 54
times in this one bundle, every one followed by a completed upload, and
it was burying the 26 TLS handshake failures in the same log that
actually cost the reporter two prints. A 426 whose bytes do not verify
is still an error and still fails the upload.
The handshake failures themselves are printer-side FTPS cool-off under
load and are not touched here.
|
||
|
|
e9daa2124e |
Match slicer presets on what they declare, not what they are named (issue #2982)
The internal slicer picked PETG for a PLA plate and an A1 process for a P1S. Both come from the sidecar's bundled-profile listing, fixed in the sidecar repo; this is the consuming half plus the hardening that keeps an older sidecar degrading rather than breaking. Standard-tier presets now carry the compatible_printers the sidecar reports. That list is the only truthful account of which printer a preset belongs to, because the bundle ships no process preset named after a P1S, an X1, an X1E or an H2D Pro -- all ten of the P1S's are named "@BBL X1C" and name the P1S only in that list. Reading the printer out of the preset NAME therefore made a P1S look like it had no compatible process at all: all 198 hid behind "Show all" and the auto-pick fell through to an alphabetically-first 0.06mm Fine @BBL A1 0.2 nozzle the CLI refused. A P1S now gets 0.20mm Standard @BBL X1C and 73 filaments instead of 4. An older sidecar reports nothing here, which leaves the name matcher in place -- degraded as before, not broken. Material is now a hard partition in the filament pre-pick rather than a +10 bonus. A preset stating a different material than the plate asks for is the wrong preset, not a worse one: wrong nozzle temperature, wrong bed temperature, wrong flow. A preset stating NO material stays eligible -- unknown is not wrong, and 32 shipped profiles genuinely have none. The same rule reaches the retain path, which held a slot on printer-compatibility alone and so cemented a wrong-material pick through every re-pick. A preset the user chose themselves is exempt: printing PETG on a plate a designer labelled PLA is a legitimate thing to do, and this rule exists to correct the auto-pick, not to overrule the user. Two more, both found while tracing this and neither reported: Among process presets equally valid for the selected printer, the one nearest a 0.2mm layer height now wins. Within a tier the list is alphabetical and Bambu's naming puts the finest height first, so every slice that did not name its own process silently got 0.08mm Extra Fine on an X1 Carbon and 0.06mm Fine on an A1 mini -- correct presets, nobody's default. Ties break toward the coarser, faster height; a name with no readable height is still pickable when it is the only candidate; a process the 3MF named still wins outright. H2DP is aliased to H2D Pro, the same shape as the A1M rename in #1649 -- the bundle spells the model one way in preset names and another in the printer preset, so an H2D Pro classified all 198 processes as another printer's. Deliberately narrow: H2DP and a plain H2D are different machines and must not collapse. A dropdown the printer filter would empty now shows the unfiltered list instead. That state was reachable for four printer models and told the user nothing; a visible preset for the wrong printer can be changed, an empty dropdown cannot. Verified against live Orca 2.4.2 and BambuStudio 02.08.02.61 sidecars over the real 1156- and 1792-profile trees: every one of the eight printer models tested now auto-picks a 0.20mm process for its own printer, a PLA plate draws a PLA preset and a PETG plate a PETG one. Each change was confirmed to fail its tests when reverted. |
||
|
|
699fc419fe |
Paint an AMS slot card with the spool's colours, not the tray's (issue #2967)
A Ziro "Colorful Mist" -- yellow, cyan and pink, effect Tri Color -- hovered on the printer card as a single flat pink rectangle. A printer reports exactly one tray_color hex per tray and nothing else, so telemetry cannot describe a gradient or a surface effect and never will. The header now paints the bound spool's own swatch whenever that spool declares extra colour stops or an effect, through buildFilamentBackground -- the builder the Inventory swatches already use, so the two surfaces cannot drift apart. A plain single-colour spool keeps the flat backgroundColor it has always had, and a slot with nothing bound is untouched, so the common case goes nowhere near the gradient path. The gate is "any stop at all", not "more than one". buildColorLayer ignores rgba the moment stops exist, so a one-stop spool renders that stop rather than the slot hex; skipping it would leave this card showing a different colour from the Inventory row for the same spool, which is the class of disagreement the shared builder exists to prevent. isLightColor now tests the colour actually on screen. Once the spool's swatch is painted the base is no longer the slot hex -- a single stop replaces it outright, and an effect-only spool paints the spool's own rgba -- so testing the slot hex would pick the text colour for a background that is not there. Above one band no single hex can decide legibility, and the name sits dead centre where a multi-stop background is likeliest to change under it. So a genuinely multi-band header puts the name on the same scrim the vendor badge already uses. One stop, or an effect over one colour, still vendor badge already uses. One stop, or an effect over one colour, still leaves a real base colour to test and keeps the contrast rule it had. Spoolman mode gains the gradient in the process. Spoolman has held the stops in filament.multi_color_hexes all along and the label renderer has been reading them for releases, but _map_spoolman_spool never returned them -- so the identical roll registered in Spoolman rendered flat while the internally-managed one did not. Both now share one parser rather than reading the same field two ways. What stays asymmetric is Spoolman's own limitation, and it is pinned by a test rather than left to be rediscovered: Spoolman has no field for a surface effect at all. Its only neighbouring field, multi_color_direction, says how the stops are laid out, not that the roll is silk or glitter. effect_type is therefore None for a Spoolman spool instead of guessed at, and silk/sparkle/wood remain internal-only. The other two halves of the report -- the header naming the colour "White" instead of "Colorful Mist", and the print dialog offering "A3: PLA (White)" -- were already fixed on dev by #2875 and by the slot-naming change that landed the day after this was filed. Neither is in 1.2.5.3, which is what the reporter is running. |
||
|
|
4f10d15584 |
Record the colour a slice is actually printed in (issue #2977)
Every file the internal slicer produced came back with filament_colour = #00AE42 whatever filament was picked: a green plate thumbnail, green metadata, and a "Color mismatch" in the Print dialog against the AMS slot the job had just been correctly mapped to. A colour is not a property of a filament preset in either slicer. It belongs to the project, and Bambu Studio and OrcaSlicer set it from the plate in their GUIs -- so nothing was attached to the preset Bambuddy sends by name, and the CLI fell back to its own compiled-in default, which is Bambu green. None of the shipped BBL filament profiles define filament_colour; the whole tree has zero occurrences. default_filament_colour is not the answer on its own. Measured against a 02.08.02.61 sidecar, a profile carrying only that still slices to filament_colour ["#00AE42"] -- Bambu Studio consumes it in the GUI when a project is created, not in --load-filaments. So it is read and rewritten as filament_colour, which the same sidecar does honour: the reporter's exact triplet returns ["#E8B00C"] when patched this way, and the colour lands in both project_settings.config and slice_info.config, which is what the thumbnail and the AMS mapping actually read. Each filament row in the slice dialog gets a colour control, and the resolved value flows through a chain: the user's pick, then the preset's own default_filament_colour, then the colour that slot was designed with in the source 3MF. A slot with none of the three is left untouched rather than given a guess. The designed colour is read from project_settings.config, not slice_info.config. The latter records what the file was last sliced with, which for a source that never carried a colour is #00AE42 itself -- measured -- so using it would have been circular. The control is offered on single-filament sources too, because an STL, and equally a mesh-only 3MF exported from CAD, has no colour anywhere else to inherit. That is the case this exists for, and it took three shapes to make it visible: a swatch in the label row was identical to the read-only dot multi-colour rows have carried for releases, and adding the hex beside it only made it look like a caption. It now sits beside the dropdown, styled like it and the same height, with the swatch and hex wrapped in one label bound to the input so a click anywhere on it opens the picker. An untouched slot with no designed colour submits an empty string rather than the control's displayed default. A sent colour outranks the preset's own, so pinning the placeholder would silently discard the real colour of an imported OrcaSlicer profile that carries one. Slicer Pipelines pick up the same chain without carrying a colour of their own. Also adds a warning for a defect found while investigating this: a filament preset whose name the sidecar's bundle cannot resolve is not rejected. The CLI inherits nothing, falls back to its defaults for every field, and returns a well-formed success -- measured, an unresolvable name slices as filament_type ["PLA"] at nozzle_temperature ["200"] with filament_ids [""] and filament_vendor ["(Undefined)"], so a PETG preset a sidecar image predates prints at PLA temperatures with no diagnostic anywhere. Both signals are required together, which keeps it off the two legitimate lookalikes: a hand-written profile that never named a vendor still carries a real filament id, and a user's own cloud preset carries a vendor while legitimately having no bundled id. The file is kept rather than refused, unlike the missing start G-code of whoever can see the temperatures. |
||
|
|
5211fd4575 |
Store a failure reason in one vocabulary, not three (issue #2974)
failure_reason was written three different ways and nothing reconciled
them. derive_failure_reason wrote English display labels ("Layer shift"),
older builds of the archive editor wrote the translated label in whatever
locale that user was running, and the two stale-archive paths wrote
English prose sentences. All three reach one column -- the archive PATCH
has mirrored the field onto the latest print-log entry since #1444 -- and
the Failure Analysis widget groups on the raw value, so one real cause
occupied several buckets. On a live install before this landed:
print_log_entries held 91 rows reading "User cancelled" beside 1 reading
"userCancelled".
In an English UI those two render as the same words twice with different
counts, which is why nobody spotted it. In any other locale one of them
stays English, because a stored label has no key for t() to resolve. The
editor was worse than cosmetic about it: its reverse lookup compared the
stored value against t() in the current locale, so for a non-English user
nothing matched and the dropdown opened empty over an archive that
plainly showed a reason.
The keys were already canonical and already enforced.
_FAILURE_REASON_KEYS in api/routes/print_log.py rejects anything else
with a 400 and explains why in its own comment -- the widget renders
values back through t(), so an unrecognised one surfaces as a raw string.
derive_failure_reason had simply never been held to that rule. It now
produces keys, and the cancel branch returns userCancelled.
The two "Stale - ..." sentences become one new noStatusUpdate key. Both
describe the same observation, that no end-of-print status ever arrived;
which of the two situations occurred is already carried by status --
cancelled at the stale-cleanup site, the reconciled outcome at the
reconnect site -- so collapsing them loses nothing and gives Statistics
one bucket instead of two sentences that could never be translated. It
had to enter the vocabulary rather than merely be tolerated, because the
editor discards any value it does not recognise.
Existing rows are converted by a startup migration folding 168 historical
labels onto the 12 keys across both columns. It is exact rather than a
guess: every label across all 14 locales resolves to exactly one key,
with no collisions. The map is a frozen snapshot rather than something
read from the locale files at run time -- it maps what was written
historically, so regenerating it from the current translations would
silently stop recognising the very rows it exists to convert. A value
outside the map is left alone; guessing would be worse than leaving one
honest string in its own bucket. There is no one-shot settings flag, on
purpose: the statement only matches values in the map and a key is never
a label, so it is self-terminating, and a flag would permanently skip
anyone who restores an older database.
The last part is a data-loss bug that was not in the report. The editor's
fallback to '' was not merely a wrong-looking dropdown -- the empty
selection was then saved over the stored text, so opening the editor on
an archive whose reason was free text and pressing Save destroyed the
classification. An unrecognised value now keeps its own option and
survives a save.
|
||
|
|
e5a18bf58b |
Key a K profile on its nozzle's flow type
A printer files each calibration under a nozzle id of the form HH00-0.4 (high flow) or HS00-0.4 (standard) and can hold both for one diameter -- a maintainer's H2D carries 102 high-flow entries against 6 standard -- because the same filament reads a different K through each. Nothing read that, so a standard-flow profile could be selected for a high-flow nozzle and vice versa. The flow is now stored with the profile, shown against each option in the picker, and checked before a stored profile is applied. Two spellings have to agree for that: a calibration entry says HH00-0.4 while the fitted nozzle reports HH01, so the comparison is two characters rather than four -- the trailing digits are a hardware variant the calibration table normalises to 00. Unknown flow on either side matches anything, which is what it has to do. Every profile stored before this has none. And an X1C declares none on any profile at all -- probed live, all eight come back with an empty nozzle id, against a four-digit cali_idx and a populated setting_id -- even though the machine really does take either nozzle. supports_nozzle_flow_type is therefore the wrong thing to gate on: it returns True for an X1C, and treating that silence as Standard would have dropped every X1C profile the moment a high-flow nozzle was fitted. What the printer's own table declares per profile is the test. NozzleInfo.nozzle_type carries two vocabularies by printer generation -- the nozzle material on legacy printers, the flow code on H2 -- and the comment claiming only the former is corrected. Anything that is not HH or HS reads as unknown, which is what makes the material spelling harmless. Storing both flows for one hotend and diameter is deliberately not done: spoolman_k_profile is UNIQUE on (spool, printer, extruder, diameter) with no flow column, and allowing a second row in internal mode alone would break inventory-mode parity. The picker marks a profile whose flow does not match what is fitted instead of letting it look configured while doing nothing. |
||
|
|
a7b563334e |
Configure a spool's filament preset and K profile per nozzle
A slicer preset is bound to a printer model: "Bambu PLA Basic @BBL X1C" is not the same preset as "@BBL H2C", and Bambu names a nozzle size in it as well. A spool carried exactly one, which was right until the same spool was used on a second machine -- the AMS slot on the other one was then configured with a preset that machine has no profile for. K profiles had the matching gap from the other side: the tables have always been keyed per hotend, but the picker could not express it. spool_filament_preset and its Spoolman twin store the exceptions, keyed (spool, printer_model, nozzle_diameter). Model rather than printer because the preset is a property of the model -- "@BBL X1C" is the same preset on every X1C, and asking per machine would mean picking the identical value twice. K profiles stay on printer_id, because a K value is measured on one physical hotend and two machines of the same model legitimately differ. Resolution is exact (model, diameter) -> (model, "") -> the spool's own preset, so a spool nobody has configured behaves exactly as it did before. The form writes one row per nozzle size and never the "" row; that level is kept for API clients wanting one value to cover a model. Both halves cover every standard nozzle size rather than the size currently fitted, because a spool is configured once and nozzles get swapped. The PA Profile tab becomes a Printers tab: a model list beside a detail pane holding a preset row per size and a K-profile grid of size by hotend. Each model is offered only the presets that name it, through the same matcher the Configure AMS Slot modal filters with, which moves out of that component into utils/slicerPrinterMatch. Presets whose name identifies no model -- most user-authored and OrcaSlicer ones -- stay offered everywhere, as does whatever is already selected, so a saved override cannot vanish from the control that shows it. Every preset carries an origin badge in the wording and colours that modal already uses. Every path that configures a slot now respects both: manual assign in either inventory mode, RFID auto-assign, the Spoolman tag link, the re-fire when a slot goes empty to loaded, the re-apply after a calibration-table refresh, and the re-selection when a Filament Track Switch moves an AMS to the other nozzle. Which nozzle a slot feeds, and how wide it is, was worked out independently in seven of those places, each reading nozzles[0] for every slot on the machine -- correct on a single-nozzle printer and on a dual-nozzle printer with matching nozzles, wrong the moment two sizes are fitted. That resolution is now services/slot_nozzle. Which array entry belongs to which hotend is no longer inferred. Measured on an H2D fitted with a 0.4 high flow on the left and a 0.6 on the right, nozzles[0] reads the right hotend, so the array is indexed by extruder id and the H2/X2 parser's convention is the one that holds. The legacy parser's opposite convention never governs a real dual-nozzle machine: every model in DUAL_NOZZLE_MODELS reports device.nozzle.info, and left_nozzle_diameter appears in no log or wire capture. Two comments that said otherwise were wrong and are fixed; amsHelpers' code was right all along and only its comment lied. Four defects surfaced while wiring it, all pre-existing except the last. The picker identified a chosen calibration by cali_idx alone, and the printer numbers its calibration table per nozzle -- on a dual-nozzle machine the same index exists on both hotends meaning different things, so saving could persist the other hotend's K value and diameter; SpoolBuddy's write-tag page carried a verbatim copy and gets the same fix. RFID auto-assign chose a K profile with no extruder test at all, so a spool calibrated on both hotends had a coin toss decide which pressure-advance value the slot got, on the path that runs unattended every time a Bambu spool is loaded. The Spoolman tag-link path resolved no preset whatsoever, configuring every linked slot with a generic material id and discarding a preset set in inventory -- the same defect #1713 fixed on the assign path, one function over. And an FTS inlet move re-selected K for nozzle 0 rather than for the nozzle the AMS had just been moved to. The last one is new here: a per-model override can be a cloud USER preset, whose PFUS-prefixed id the slicer rejects, and passing it straight into extrusion_cali_sel would silently lose the K-profile link. Reached the printer only where such an override exists, which is why nothing in the suite caught it. printer_safe_filament_id falls through to the spool's own preset and then the tray's RFID value instead. Reading a printer's calibration table asks for one nozzle size at a time. H2-series firmware answers only the first one or two of a concurrent burst of extrusion_cali_get and silently drops the rest, each dropped request costing a five-second timeout before its retry: measured at 11 and 23 seconds on an H2C and an H2D for four parallel requests, against roughly one second in series. An X1C answers all four at once, which is why this only ever surfaced on dual-diameter printers. Printers themselves are read in parallel -- separate machines are separate connections. The Configure AMS Slot dialog opens on the spool's own configured values, falling back to the slot's last manual configuration and then the tray's RFID data. The spool form is wider for the two-pane layout, colour, weight, cost and location move to their own tab in two columns, and a printer card in expanded view lists every fitted nozzle size rather than the first entry alone. |
||
|
|
9500c046c0 |
Ask which nozzle to feed when a Filament Track Switch is fitted
Load and Unload in the AMS slot menu did nothing on an H2C with the switch fitted. The ams_change_filament command carries an optional extruder_id and Bambuddy never sent it. That is correct on every printer without the switch, and is what BambuStudio does there too -- each AMS is wired to one hotend, so the firmware works the target out for itself and an explicit value would only be a guess at something it already knows. Fit the switch and every AMS is bound to one of its two inlets instead, either hotend is reachable from any slot, and a command naming neither leaves the firmware nothing to act on. It was discarded in silence. Load now asks which hotend to feed, on the same terms as Bambu Studio: no preselection, so a stray Enter cannot feed the wrong one, and the hotend already fed from that very slot greyed out. Printers without a switch send a byte-identical command and still load in one click. A switch fitted but not yet set up -- any AMS still unassigned to an inlet -- refuses the load up front rather than publishing one the firmware will drop, mirroring DevFilaSwitch::IsReady, which likewise demands a switcher position on every AMS. Unload was addressed at the same time. It was aimed with tray_now, a single value for the whole printer, so on any dual-nozzle machine with both hotends loaded it unloaded whichever that field happened to name regardless of which slot's menu was used. It now names the slot and resolves the holding hotend from device.extruder.info, previously read for temperatures only. That resolution is gated on the printer having reported two extruders: single-nozzle machines do send the block, but nobody has read a single-nozzle snow value off the wire, and staking every X1C, P1S and A1 unload on an unverified encoding buys nothing where tray_now is already unambiguous. Both new state fields ride the WebSocket and are in the broadcast key, and both are computed in the REST status route as well -- that response is what the page has before any push arrives, and leaving them at their defaults would have told a correctly set-up machine that its switch was not set up. Verified on H2C-1, AMS-A slot 3: loaded and unloaded from each hotend in turn, all four correct. Covered by 18 MQTT unit tests, 4 status-dict tests, 7 integration tests and 6 component tests. Two known stragglers, both deliberately left alone. Load on an AMS-HT slot has never worked -- an HT unit is addressed by its unit id rather than ams*4+slot, which these endpoints do not accept -- so unload there keeps the printer-wide form it always used instead of gaining a slot it cannot name. And a slot's K-profile still follows the AMS's plumbing rather than the nozzle just loaded, so loading to the far hotend leaves the other one's calibration bound; that is the same per-nozzle problem the filament and K-profile redesign is scoped to fix. |
||
|
|
196fcaf15b |
Keep the last run a batch order can re-queue a plate from
An order produces what it still owes by cloning an existing queue item for the same plate. That row is the only record of the printer target, AMS mapping and print options the user chose, so deleting the last one left the order reporting work outstanding that nothing could produce, and no way to close it out: Cancel was hidden unless there were pending items to cancel, which by then there were none. Deleting an order's last surviving run for a plate now cancels it instead. A cancelled run does not satisfy a target, so the order still owes the print and can still make it. A completed run is exempt and still deletes outright -- rewriting it as cancelled would falsify what the order produced. Also: the response reports per plate whether anything is left to clone, so the card explains a stranded plate rather than offering a button that can only fail; dispatch skips a stranded plate instead of aborting the whole order; and Cancel is offered for any active order. |
||
|
|
a68933cb2f | Allow Avery label sheets to start at an unused position (#2879) (#2918) | ||
|
|
14f46510ba |
File the printer's calibration table under the nozzle it belongs to (issue #2854)
The K value on an AMS slot card went blank after a while and came back after a backend restart. It is not the MQTT merge: that preserves a tray's k correctly. H2-series trays have no k to preserve. Verified against the H2 wire capture in logs/vp_wire -- every tray reports cali_idx and nothing else -- so the number on the card is resolved from that index against the printer's calibration table in state.kprofiles, and that table was a single global list. An extrusion_cali_get response is the complete table for one nozzle diameter, and the printer answers whoever asks; BambuStudio's queries arrive on the same report topic we subscribe to. Every response was assigned straight to state.kprofiles, so any one answer stood for the whole printer. The nightly GitHub backup asks for 0.2, 0.4, 0.6 and 0.8 in turn and finishes on 0.8, which holds nothing on a 0.4+0.6 machine: logs/bambuddy.log records exactly that at 17:15 on 2026-08-25, and the table was empty from then until something refilled it. Responses are now bucketed by the diameter they describe, so an empty answer for a size the printer does not have clears only that size. The three assign paths that look an index up by nozzle_diameter get the same fix for free -- they were quietly finding nothing whenever the last response was for another nozzle, which is what spoolman_inventory has been logging as a stale kp. Bucketing makes state.kprofiles a union, and cali_idx is numbered per nozzle, so the index alone no longer identifies a profile. The REST serializer has keyed on (extruder, cali_idx) since c5e005586; the WebSocket one still keyed on the index alone, which meant the first render of a card could be right and every update after it wrong. Both now share one resolver. It goes through the extruder the slot feeds, and where that does not single out one profile -- a single-nozzle printer that has been swapped, so both its tables sit under extruder 0 -- it falls back to which diameters are actually fitted. Where neither settles it the card shows nothing, because a blank space is a smaller error than confidently printing the other nozzle's number. Deliberately no loosening to a bare cali_idx lookup on a miss: that is the cross-nozzle bleed the extruder keying was added to stop. Nothing read the table on connect, which is the other half of the report. It arrived by luck -- a visit to Profiles or Configure Slot, a backup, or the printer answering someone else -- so a Bambuddy nobody had opened showed a card with no K values at all, and "restart and they come back" was the printer happening to broadcast rather than anything we did. It is now read once per connection, on the same latch the stale-print reconcile uses. Only the fitted diameters are asked for, one request on a single-nozzle printer and two on a dual; probing the four sizes blind is what the backup does and what blanked the table. The edge is gated on a nozzle diameter being known as well as on the state being known, because the first push_status is what makes the state known and does not always carry the nozzle fields -- latching there would spend the connection's one attempt on a printer that could not yet say what was fitted. Adopting an unsolicited table now logs at debug. It was the quietest way for the card to change underneath us and there was no way to see it in a support bundle. Three test files gained nozzles=[] on their PrinterState stubs. The field has always been on the dataclass; the connect edge is simply the first thing on that path to read it. |
||
|
|
08a58b1ef4 |
Keep both modes' slot assignments across an inventory mode switch (issue #2812)
Turning Spoolman mode on ran an unfiltered delete(SpoolAssignment) across every printer. Turning it straight back off cleared the other table instead, so the two directions were symmetric in code and one-way in effect, and the setting auto-saves on a 500 ms debounce with no save button and no confirmation. Opening the settings page to see what the option did was enough to destroy the configuration: the reporter's log shows four toggles in 85 seconds, which is someone looking and reverting, and the assignments never came back. The deletion was not careless. Checks that read both assignment tables would otherwise let a row in the mode you are not using answer for the mode you are, which is how #1473 was fixed, and emptying the inactive table made that impossible by construction. The cost was that the guarantee was bought with the user's data. That decision belongs to the readers -- the mode is a property of the install, not of the rows -- so spoolman_owns_assignments now answers it and nothing is deleted on a toggle. Each mode keeps its own assignments and switching is reversible. Existing installs need no migration: their inactive table is already empty, because it was being emptied. Six sites had to be told which mode they meant, and only two of them are the reads you would guess at, the missing-assignment notification and the queue cost estimate. The per-slot K-profile lookup consults the built-in table first and, on a hit with no matching profile, deliberately stops rather than falling through to Spoolman, so a leftover row would have shadowed the Spoolman binding for that slot -- the symptom #1556 reported from the other direction. configure_ams_slot *writes* a K-profile against whichever table answers first, so the same leftover would have filed a calibration against a spool the printer is not drawing on and never written the local one, leaving a calibration that appeared to succeed and then did not apply. The auto-unlink pass in on_ams_change is the one that would have made this change worthless. It drops any assignment whose tray no longer matches the fingerprint it recorded, and it ends in db.delete. Ungated, it would have removed the preserved rows one slot at a time as the AMS contents changed under the other mode -- the same loss, arriving slowly enough not to be connected to the toggle that caused it. The sixth is the built-in remaining-weight fallback inside the Spoolman AMS sync, and it is deliberately left inert rather than woken up. It could never fire while the table it reads was being emptied, it is keyed by slot rather than by spool, and create_spool writes remaining_weight unconditionally where the update path does not -- so preserving the rows would have seeded a stale figure into a brand new Spoolman spool the first time a tray reported an unusable remain%. The query stays, gated off, so the intent survives for whoever revisits the cross-mode fallback. Separately, a print that could not debit a spool said nothing about it, and that is what turned a mis-click into lost filament. The reporter's print was already running when they toggled. At completion it resolved its 3MF, read its per-filament grams, resolved its tray, and then skipped the debit because the assignment row no longer existed -- logged at INFO, invisible under the default log level, while the completion notification fired as usual. 65.49 g was never deducted and they only noticed because a spool's remaining weight looked wrong. _resolve_spool_id_for_tray has no tag or fingerprint fallback, so there was nothing else to catch it. The skip is now a warning naming the grams, and a completed print that failed to charge a tray it drew from raises the missing-spool-assignment notification. The print-start check cannot cover this and was right to stay quiet: the assignments existed when it ran. The two are different statements -- the first says the weight may not be tracked, the second says it was not -- so a print warned at start will notify twice, which is the right trade. Collected across the print rather than fired per slot, and given the caller's session, because this runs inside on_print_complete's transaction and opening a second one to read the printer's name would deadlock against it on SQLite. This is independent of the toggle and catches any other cause of an assignment disappearing mid-print. |
||
|
|
39835437a3 |
Price a print from the spool that fed it, not the default rate (issue #2591)
Spoolman holds per-spool pricing, and #261 gave that as the reason for integrating with it. Nothing ever read it. A print's cost is set once, at archive time, from the built-in Filament catalogue matched on the primary type and falling back to a global default rate -- and in Spoolman mode nothing revisited that figure afterwards. The per-spool recompute that would have fixed it, in usage_tracker.on_print_complete, runs only over rows the built-in inventory writes, and Spoolman mode hands the usage tracker spoolman_owns_usage at print start so it writes none. The reporter's catalogue was empty, which is the ordinary state of one in Spoolman mode, so every print came out at the default no matter what the linked spool cost. Multi-material was wrong twice over there: the primary type's rate applied to the whole print's weight, so a slot of expensive PA was billed at the price of the PLA beside it. Each slot is now priced from the spool it was actually charged to, at the moment of the charge, and the per-slot costs are summed -- which is what fixes the multi-material case, rather than a separate change. All three charge paths feed it: per-slot, tray-split, and the remain%-delta fallback. The rate is the spool's own price when set, else the filament's, over filament.weight. That is net grams excluding the core, and the same field the remain-delta path already divides by to turn a percentage into weight, so a spool that can be charged by percentage can always be priced. The price comes out of the get_spool call the colour and material rewrites already pay for, so the tagged path costs no extra round trip. Grams no spool could price are covered at the global default in one subtraction against the archive's own total. A spool with no price, a tray with no Spoolman row, and filament the sliced file never attributed are the same case from here, and without the top-up a print with one priced slot out of four would report a quarter of its cost -- #1344 in the other inventory mode. Only the first run writes the archive, matching the built-in writer (#1378); reprint actuals live in PrintLogEntry. If no slot could be priced at all, whatever archive.py recorded is left alone, so an install with prices in neither place stays where it was. Applied even when the slot-to-tray mapping was a positional guess, unlike the colour and material rewrites beside it. Those overwrite what the slicer recorded, which is why a guess must not touch them. The cost has no such original -- archive.py's figure is itself derived from a default rate -- and the grams have already been deducted from these spools, so the archive should say what that deduction was worth. Both cost recalculations would have undone it on the next run. /rescan and /recalculate-costs rebuild an archive's cost from SpoolUsageHistory and fall back to the catalogue or the default when there are no rows, which in Spoolman mode is always, so the fallback was not a recalculation but a downgrade. The spool-to-slot resolution a price is derived from exists only while a print is completing and cannot be rebuilt from the archive row, so both now leave a cost alone rather than replacing it with a worse one, and the bulk endpoint reports how many it kept. An archive with no cost yet is still priced, and with Spoolman off both behave exactly as before. The rate parser refuses more than it looks like it needs to, because everything it refuses was reachable. A non-dict filament raised through a call that sits after a successful use_spool, which would have abandoned the remaining slots of a multi-material print with the charges already made. NaN compares False against every bound, including the applier's own total <= 0, so a NaN price would have been written to the archive with nothing downstream able to clear it; two finite operands can produce it by overflow, so the quotient is checked as well as the inputs. A bool is an int in Python, and float(True) is 1.0 -- a weight of 1 g prices a spool per-gram at its whole cost. And a spool-level price of 0 now falls through to the catalogue rather than reading as free: Spoolman leaves the override null when unset, but importers write 0 often enough that treating it literally would price a whole print at the default with a good catalogue price one level down. |
||
|
|
c3677865b6 | Give the AMS temperature alarm its own threshold (issue #2905) (#2943) | ||
|
|
73912d4f05 | Let a clear spool stay clear on the way to Spoolman (issue #2912) (#2924) | ||
|
|
54af3146a3 | [Feature]: Bind Home Assistant sensors to storage locations (dryboxes/bins) (#2827) | ||
|
|
5dd7bd213f |
Read a print's destination from the report topic, not just the request one (issue #1820)
current_project_url was assigned in exactly one place, _handle_request_message, and _on_message calls that only for the request topic. A print started from the printer's own screen publishes nothing there, so the field stayed None for the one case the storage verdict exists for: the file is already in the printer's model library under /userdata/model/history/, which port 990 does not serve. The verdict then fell through to the sdcard flag, and @ojimpo's H2S reports that flag true -- its "card" is the internal eMMC -- so every such print ran the full sweep before giving up. He measured one: 16 filename-and-directory attempts over 22 FTPS connections, 18 of them refused, 6.4 seconds, then a fallback archive holding a name and nothing else. The printer does announce where the file lives, as an unsolicited project_file *response* on the report topic about two seconds before gcode_state reaches PREPARE. _process_message now reads the url off it, gated on result SUCCESS and a non-empty value so a refused dispatch cannot name a file that was never written. Reading it there rather than only at the request topic also covers an install neither of us had in view: some brokers refuse the request-topic subscription, and on those no print of any kind had ever populated the field. The new branch captures state and nothing else. The "External project_file payload" diagnostic stays with the request-topic handler: our own dispatch is echoed on both topics, the request-topic echo lands first and clears _own_project_file_key, so reusing the diagnostic here would have logged every Bambuddy-started print as somebody else's. A test pins that. What the print names is now what gets tried -- the five directories a copy could be in, rather than the ~110 connections that cannot succeed. The probe is still worth running: an H2S keeps recently used jobs under /cache and archives them in full while they last, which is why the reporter's two prints on the same day behaved differently. Slicer-sent prints are unchanged. The banner no longer describes a step that never happened. With no reason recorded, a blank archive fell back to the original wording -- "Store sent files on external storage" is off in your slicer -- which on that printer is on, and which the internal-storage wording from #2780 already explains would not help on an H2. The archive that most needed that explanation was the only one that could not be given it. So file:///userdata/ now earns its own reason, internal_history, separate from the brtc://emmc dispatch case. A dispatch chose internal storage and can be aimed elsewhere; a print of a file that was already there had no dispatch at all, and telling that operator to pick External in Send names a dialog they never opened. The banner and the connection diagnostic both read the verdict's reason rather than a fixed one, so the two surfaces cannot give the same printer different advice. Thirteen locales, and a wiki section the banner links to. ----- Read the K-profile selection when the mutation runs, not when it is captured Configure Slot sends cali_idx from selectedKProfile, and the mutation read it through its own closure. React Query hands a mutation its options from an effect, so a click landing between a commit and that effect flushing runs the previous render's mutationFn -- one that captured the selection as it was before the K-profile query resolved. The payload then carries cali_idx -1 and the printer binds the default 0.020 instead of the calibrated K, while the dialog shows the right profile selected throughout. It surfaced as an intermittent failure of the per-nozzle K-profile test, about one full-suite run in six. Reproducing it with staggered query resolution showed the divergence directly: the select element held the correct profile immediately before and after the click, and the payload still carried -1. That test's slot is the most exposed case in the file -- a right-hotend slot carrying the left hotend's index, where the "keep showing the active profile" safety net cannot repair an empty recompute. The selection now goes through a ref written during render, so the mutation resolves it at execute time. An effect would have inherited the same flush ordering this exists to escape. The K value and the profile's ids travel in the same payload and had the same exposure, so they move with it. Measured over a staggered-resolution grid: 2 failures in 15 runs before, 0 in 12 after. api.getSlicerPrinterModels was also missing from the test file's mock, so that query ran with no query function and rejected in all 37 tests -- mocked now, though on its own it changed nothing, which is how the ref was confirmed as the fix rather than assumed. |
||
|
|
4e79f9c2f7 |
Fill in a fallback archive when the 3MF finally arrives (issue #2957)
A failed TLS handshake pauses a printer's file service for five minutes, and the archive flow checks that pause at the top of its path loop and gives up before opening a connection. A print that starts inside one gets an empty fallback archive 13 milliseconds later, having never touched the network. Four minutes on, the pause clears and the cover endpoint downloads the same file, parses it, takes a thumbnail out of it, and publishes it to the shared 3MF cache under the exact key the archive flow looks up. Nothing ever looks: all three readers of that cache run before or during the print-start handler that already gave up, and print completion drops the cache as its first act, deleting the file. No path existed by which a fallback archive could become a real one. Offer a later 3MF to the running print's archive, filling the existing row rather than adding a second -- the row id carries the energy reading, the timelapse session and the start notification. That covers the reported case at no network cost, since opening the printer card already downloads the file. Schedule a bounded retry when the pause is what caused the fallback, spending the cache first and the printer only if that misses. Not scheduled for the other cause: a print kept on internal eMMC has no FTPS copy to come back for, and retrying it is the sweep removed in #2780. The two reasons are now recorded separately instead of both landing as "no 3MF". Recovery refuses anything that is not a readable 3MF -- a truncated download would replace an honest empty archive with wrong metadata -- and leaves an archive alone once it has a real file. Three things the recovery path has to get right, each with a test: Reduce every retry candidate to a bare name. MQTT hands `filename` over as "/data/Metadata/plate_1.gcode" on some firmware, and joining that onto the temp directory yields the absolute path itself, so the retry is fed the flow's own sanitised candidate list and strips the path again on its own account. Serialise recovery per printer. The cover endpoint's single-flight coalesces by view, so two views race each other, and the retry task and print completion can land on top of either -- each reads file_path == "" and runs a full copy, leaving the row on one timestamped directory and the rest orphaned. Keep looking for photos in the pre-recovery directory. `archive_dir` derives from `file_path`, so filling the row moves the archive's directory, and a photo uploaded to the empty card while the print ran stays where it was put. |
||
|
|
06e5114a03 |
Do not report a printer as Safe when nothing is checking it (issue #2952)
The printer card's AI badge collapsed every class that was not Warning or Failure into green Safe, and the service reported `safe` whenever it had no verdict. The state entry is created when a monitored print is first seen -- before the first snapshot, let alone the first inference -- so a rejected ML API token, an unreachable ML API, a failed capture and an unset External URL all rendered as a healthy watched print: green Safe at score 0.000. For a safety feature that is the worst failure mode available: it asserts the print is being watched exactly when it is not. The reporter read that badge and concluded the loop had never started. It had been calling the ML API every ten seconds and being turned away with a 401 -- invisible because Obico's auth layer rejects a bad token before its request log sees it, and because successful checks log nothing there either. Add two honest states. Not checking (amber) when the last poll produced no result, carrying the reason; Starting while a monitored print waits for its first result. Score and frame count are withheld while not checking, since 0.000 beside "Not checking" reads as a measurement rather than its absence. The reason is per printer, so a card names its own problem rather than whichever printer failed most recently, and stays behind settings:read because it can quote configured URLs -- the badge state does not, because whether a print is watched is not configuration. An unrecognised class now falls back to Starting, not Safe. Test Connection saves the form before probing, so a green result describes the configuration the loop actually runs with rather than what is typed in the boxes. |
||
|
|
b1f5ec9642 |
Let one checkbox say where a slice's settings come from (issue #2942)
Two features in the slice dialog read as one. "Use the file's built-in settings" slices a 3MF the way its designer set it up, ignoring the picked profiles. The per-option "from file" ticks beside each setting carry the designer's individual deviations onto the profile you picked, and those arrived pre-ticked whatever the checkbox said. So a slice run deliberately without the file's settings still took sixteen values out of it -- the reporter's log names them, enable_support and support_type among them, landing on a process preset they had chosen on purpose. The ticks now follow the checkbox. Off, nothing comes out of the file until it is asked for by name; on, every setting the file changed shows ticked, because on that path the file really does drive the whole slice. Taking the designer's work in bulk is still one click, from a line at the top of the panel that says how many settings the file changed -- it is the only way left to reach them without hunting for chips across six pages of 348 options -- and it still leaves the machine-tuned keys and the two that are the picked preset for a per-key decision. The panel greys out options the slicer's own rules switch off, and it was evaluating those rules against what the user had typed alone, falling back to the compiled-in schema defaults for the rest. A preset with supports on therefore read as enable_support: false and greyed out the whole Support page while the slice ran supports. A greyed row greyed its tick too, which is how the reporter's screenshot shows a support type marked "from file", applied to the slice, and impossible to clear. The rules now see what the slice will actually run with: the preset's values, the file's values for the keys that are on, and anything typed on top. The tick is no longer gated on those rules at all -- it answers a different question, not whether an option is in play but where its value comes from. Underneath both, the support carry-over ran outside the ticks entirely, lifting four keys out of any 3MF that had supports on with nothing on screen able to decline. It now stands down for the keys that were offered and turned down, which the request can say for the first time: an empty design_overrides list means the caller was shown the file's settings and took none, where no list at all is a caller that predates the choice. That distinction is what keeps the carry whole for sources with no deviations to tick, an OrcaSlicer export among them, rather than trading one silent default for another. Worth knowing: a Bambu Studio file with supports enabled no longer switches supports on for you. Tick Enable support, or the checkbox above the panel. Measured against the reporter's own sixteen keys, and covered by backend and frontend tests -- reverting any one of the three changes fails tests. |