Commit Graph
3737 Commits
Author SHA1 Message Date
maziggy e6842e1d3c Rank near-colour matches by how they look, and share one filament type table (#2804)
Three follow-ups to #2804, all bearing on one decision: which spool a print
uses when the exact colour is not loaded.

Colour ranking is now perceptual. The ranking added in #2804 measured RGB
distance, which rates a colour by how far apart the numbers are rather than
how far apart they look, and it overweights blue badly enough to invert the
answer: against a required #1E4821 green, a purple #38202F is the nearer of
two eligible spools by RGB and four times the further once measured properly.
Both sides now use CIEDE2000 -- perceptual_color_distance in
backend/app/utils/color_utils.py and colorDistance in amsHelpers.ts, kept
structurally identical so they can be read side by side. Verified against the
Sharma/Wu/Dalal published reference set, all 31 pairs to 1e-4, and the two
implementations agree to within 1e-9 across 800 sampled pairs. Eligibility is
untouched, still the per-channel RGB box, so this only reorders spools that
already qualified.

Type matching now agrees between the interface and the scheduler. Bambu
firmware treats PA-CF, PA12-CF and PAHT-CF as one material and the scheduler
has always matched them accordingly, but the interface compared raw type
strings and called that same pairing a mismatch. The badge contradicted what
the printer was about to do, and the manual override picker, which groups by
canonical type, offered the very spool the badge then rejected. The fifteen
comparison sites in useFilamentMapping.ts, useMultiPrinterFilamentMapping.ts
and PrinterSelector.tsx now call filamentTypesCompatible.

The pipeline pre-flight reads the matcher's table instead of its own copy.
That copy had drifted into disagreeing in both directions: it aliased PLA
Basic to PLA where the matcher never has, so a run could clear the check and
then fail to map its slots, and it lacked the nylon grouping, so it flagged
runs the matcher handles without complaint. A check whose job is to predict
dispatch is wrong whenever it disagrees with dispatch, whichever way it leans,
so it and the scheduler now both read backend/app/utils/filament_types.py.

That canonicaliser deliberately does not strip surrounding whitespace. It
looks like a free improvement, but it would collapse a junk tray_type to ""
just as a 3MF declaring no filament type yields "", and a typeless requirement
would start matching a junk-typed tray instead of reporting the slot unmapped.
Padded type strings are worth handling on their own terms, with that case
addressed.

One behaviour change outside the ranking: the pre-flight is stricter for a
printer reporting a product name such as "PLA Basic" where the generic
material belongs, which it now flags rather than passes. Rare in practice,
since the printer reports material and product name in separate fields, and it
is the answer the matcher would give. Nothing about which spool a print
actually uses changed outside the colour ranking itself.

Adds 203 backend and 6 frontend tests. The #2804 tie-break test now uses
identical colours: two colours at equal RGB distance are not perceptually
tied, which is rather the point.
2026-08-13 12:04:56 +02:00
Martin Grolmus 4f7a02b393 Pick the nearest eligible filament colour instead of the first one in tray order (#2804) (#2823) 2026-08-13 11:19:00 +02:00
maziggy 36d996e453 fix(slicer): stop a 3MF from switching off supports its process preset turned on (#2820)
--load-settings is authoritative, so since #1881 four support fields
travel the other way -- enable_support, the two filament slots, and
support_type -- lifted out of the source 3MF and written over the picked
process preset. Bambu's shipped presets all set enable_support: 0
because supports are a per-print decision, and without the carry a
project exported with PVA in the interface slot sliced single-material.

But the carry ran in both directions, and the off direction is the one
nobody asked for. Nearly every published model ships with supports off,
so slicing one against a custom preset that deliberately enabled them
stripped them back out. The reporter's preset sets enable_support 1,
support_type normal(auto), support_style snug; the slice came back
disabled and tree(auto). Only the style survived -- it is not one of the
four carried, and they had re-entered it in the slice dialog.

The source can now switch supports on, never off. Nothing is lost:
every shipped preset has them off, so a preset that has them on is a
deliberate choice by whoever wrote it, and a file that wants supports
still gets them with its slot assignments. A file that never declares
enable_support is treated as off -- no intent to act on.

The truthiness rule ("1", true, 1, and the forks that write neither) now
lives in one place as supports_enabled_in_config(), shared with
extract_support_filament_slots_from_3mf, which had it inline.

Also log the carry with the fields it took. The slice dialog shows the
picked preset's values, so a carried field silently disagrees with what
was on screen and this step logged nothing at all -- the report chased an
unrelated sanitiser line about the source file's own settings, which was
the only thing in the log that mentioned any of these keys.
2026-08-13 10:41:22 +02:00
maziggy 02616f0c91 fix(queue): stop a library-file delete from destroying the jobs queued against it (#2819)
Nothing tied a library file to the queue rows pointing at it, and the FK
that describes the relationship is ON DELETE CASCADE -- which SQLite does
not enforce and PostgreSQL does. So the same fault had two faces: rows
left pointing at a file that no longer existed, failing at the printer
with "Library file not found" days later, or rows deleted outright with
no error and no history.

Two routes into it, both fixed by taking the queue off the file before
the row goes.

Dispatch (the reported case): quantity>1 on the printer-card
upload-and-print flow puts cleanup_library_after_dispatch on every copy,
and _clone_queue_item copies library_file_id onto batch clones, so the
first dispatch consumed the file the rest were waiting on. The copies are
now pointed at the archive that dispatch just created -- it holds its own
copy of the 3MF -- and the consume flag is cleared on them. A copy already
printing from its own archive keeps it, a finished one keeps its outcome,
and a cross-model item (#671) keeps any candidate this does not consume.

Deletion: the File Manager, bulk delete, folder delete, emptying the trash
and the retention sweeper all removed rows with queued work against them.
Folder delete did not even clear the cross-model candidates, because the
file-id walk it already performs threw its result away. Jobs waiting on a
deleted file are now cancelled at that moment, naming the file, and every
other row referring to it is detached rather than destroyed -- print
history and batch progress are counted from those rows. A job that is
printing is left alone: what is deleted is the library copy, not the copy
on the machine. The trash is reversible so it still changes nothing about
the queue, and a job dispatched while its file is in the trash now says so
instead of "not found".

Verified row for row on PostgreSQL 16 as well as SQLite: without this,
PostgreSQL deletes every queue row referencing the file.
2026-08-13 10:26:59 +02:00
maziggy 12e0a60e8b fix(ui): stop the Spool Inventory header from scrolling the page sideways (#2813)
Five header buttons in a row that could neither wrap nor shrink came to
~600px, so on a 390px screen the header ran past the viewport and took the
whole page with it -- <main> is the scroll container, so everything inside
it panned.

Stack below sm and wrap the actions, matching the pattern the Statistics,
Settings and Archives headers and this page's own filter bar already use.
Identical at >=640px. The System Information header had the same
construction with one button and gets the same treatment.
2026-08-13 09:31:13 +02:00
maziggy 454457a0af Attribute filament correctly when AMS backup swaps spools mid-print
Everything the completion path needs to split a print's filament across
the trays it fed from lived only in memory: the dispatched plate and
slot-to-tray mapping, the spool-assignment snapshot, and the tray-change
log. A print that outlived a restart lost all of it and fell back to
what the printer reports at completion -- which, with AMS Filament
Backup on, is the substitute tray. The whole print was charged to the
spool that only finished it while the spool that ran dry was charged
nothing.

Persist that context in a new active_print_sessions row, append tray
changes as they happen, and restore both the session and the printer's
tray-change log at restart recovery. Seed the log from the current tray
when there is nothing to restore, since last_loaded_tray advances even
when no change is logged.

Rank the queue item's stored ams_mapping above the printer's live
mapping field, which is what backup rewrites. Recover plate_id from the
archive or queue item, and give extract_layer_filament_usage_from_3mf a
plate_id instead of taking the first .gcode member -- a Bambu Studio
export stores plate 2 first, so per-layer figures were measured against
the wrong plate for both inventory backends.

Stop auto-unlinking a spool assignment when its slot reports empty
during a running print. At a runout the spool is still in the AMS, and
dropping the link leaves the completion path nothing to charge.

Capture the print-start context for both inventory backends. Spoolman's
own durable row (#1820) carries its plate-scoped figures and dispatched
mapping but not the tray-change log, and its slot assignments -- the
way. Registration in _active_sessions stays gated, since on_ams_change
reads it to decide whether to skip the remain%-based weight sync (#880).
2026-08-13 08:37:16 +02:00
maziggy b5a34b7ba7 Do not close a queue item on a completion for another print
on_print_complete finds the row to close by printer and status='printing'
alone. The MQTT payload carries a subtask name but no run identifier, so
nothing tied the event to the row: any completion delivered for a printer
closed whichever job was printing on it. A job closed that way is marked
completed while the printer is still working, leaves the queue for
history, and strands the rest of its batch, because the queue correctly
refuses to dispatch onto a busy printer.

The handler now checks the completion against the file the row was
dispatched with, recovered from its archive, and leaves the row alone
when they disagree. Only a positive disagreement refuses: no archive, no
file name or no subtask name is unverifiable rather than wrong, and
refusing those would strand the item in 'printing' and wedge the queue --
the failure the loose lookup was avoiding in the first place.

This surfaced through the test suite, which could reach a real database.
conftest built its own SQLite engine, but core/config.py snapshots
DATABASE_URL at import time and core/database.py builds the module-level
engine and async_session from it. Tests reaching code that opens its own
session -- run_with_retry, which the completion path uses, takes its
sessions from core.database and so is untouched by the widespread
patch("backend.app.main.async_session") -- therefore talked to whatever
database .env named: the developer's own SQLite file on a plain checkout,
a live install with a PostgreSQL .env. DATABASE_URL is now redirected to
a throwaway file before any app import, and the run aborts rather than
starts if that did not take.
2026-08-11 12:50:42 +02:00
maziggy c06ce2be88 Updated CONTRIBUTING.md 2026-08-11 12:46:12 +02:00
maziggy 4a65abe228 Tell the browser which colour scheme the page is in
The parts of a form control the browser draws itself -- a number input's
stepper, a date field's calendar button and popup, a select's dropdown,
scrollbars, the autofill tint -- were painted in the light appearance on
every theme. Bambuddy switches theme by swapping CSS variables under a
`dark` class, which the browser cannot see, so it assumed the page was
light and matched the steppers to a white background that was not there.

Declaring color-scheme alongside the variables fixes all of them at once,
in both directions. It goes on `.dark` rather than the per-palette
classes because the kiosk sets `dark` on the root element directly.

Three date and time fields had been pinned to dark by hand to work
around this and no longer need to be; being pinned, they were wrong
under the light theme anyway.
2026-08-11 12:16:31 +02:00
maziggy 0229d61cea Open multi-plate G-code on the plate that was asked for
Previewing a sliced multi-plate 3MF from the File Manager showed a plate
nobody picked. The library route took no plate parameter at all, so the
one the viewer has always put in the URL was dropped -- FastAPI discards
unknown query parameters silently. Both routes then fell back to the
first .gcode member of the zip, and member order is whatever the slicer
wrote: the reported file stores plate_2.gcode ahead of plate_1.gcode.
Nothing that opens the viewer from the File Manager passes a plate, so
there was no way to ask for another one either.

Plate resolution now lives in threemf_tools and both routes share it.
select_plate_gcode_name() returns the named plate or None, so a caller
serving an explicit choice can 404 instead of rendering something else;
default_plate_gcode_name() returns the lowest-numbered plate. The viewer
gained a plate switcher, and keeps the choice in its URL so a link to one
plate survives a reload. Filament colours follow it too -- they were
taken from the first plate regardless of which one was on screen.

G-code injection and the finish-photo max_z_height read shared the old
first-member fallback and now resolve the lowest plate as well.
2026-08-11 11:49:30 +02:00
maziggy df5aa04df1 Pool AMS backup spools in the print dialog's filament check
The dialog weighed each plate against the spool in the slot it mapped to
and knew nothing about AMS Filament Backup, so a two-plate job needing
1441 g of ABS was refused against a 1000 g spool while the identical full
spool in the next slot went uncounted. The dispatcher has pooled matching
spools since #1762 and would have run the print -- "Print anyway" was
always the right answer to this warning.

The rule for which spools back each other up now lives in one place:
build_slot_materials() in filament_deficit, which the dispatcher's pool
and the new slot_materials half of GET /printers/{id}/inventory-remain
both draw on. The dialog groups on the keys it is handed rather than
resolving spools a second time, which is what let the two answers drift
apart, and which also gives the check to Spoolman users -- it read the
internal inventory only, so in Spoolman mode it approved everything.

Where a pool really is short the warning quotes the pooled totals, since
the per-slot figure reads as a contradiction next to a full peer spool.
2026-08-11 11:32:17 +02:00
maziggy e93f1b43c3 chore(settings): drop the Slicer Bundles notice and rebalance the columns
Bundle import was withdrawn in 0.2.5 and the panel was kept behind as a
static notice pointing at the alternatives. It has been on screen for
several releases, it was shown to everyone running the slicer sidecar
whether or not they had ever imported a bundle, and it was a card in
Settings -> Queue & Dispatch that could not be acted on. Component,
render site and all thirteen locales' strings are gone; no slicing
behaviour is touched.

Four docstrings still described the removed feature as a live fallback:
SliceModal was said to fall back to "the user's uploaded Slicer Bundles"
when a preset carries no compatible_printers. There is no bundle model
and no bundle endpoint left -- the actual fallback is the @BBL <code>
printer-model registry, which is what SliceModal has been doing since

Removing the card left the left column of that tab noticeably longer, so
G-code Injection moves to the foot of the right column. The card is
unchanged and keeps its card-gcode anchor, so settings search still
jumps to it.
2026-08-11 09:57:20 +02:00
maziggy e9d9e51a81 fix(h2c): send physical nozzle IDs for both carriages, not extruder indices (#2800)
The first pass at the H2C rack mapping had both of its hardware-derived
values wrong, and the reporter's follow-up A/B on real hardware settled
them.

The rack does not feed extruder 0. A mixed-nozzle plate extracted as
slots=[0, -1, -1, 1] with rack position 17 dispatched as
[17, -1, -1, 1], and the rack nozzle printed several millimetres above
the bed. The rack is extruder 1.

Correcting only that is not enough. The fixed hotend answers to physical
ID 1, not to its extruder index of 0, and forwarding the index produced
[0, -1, -1, 17] -- a command the printer rejected outright rather than
mis-printing. Translating both carriages gives [1, -1, -1, 17], and the
same sliced file then cleaned, levelled and printed on the correct
nozzle at the correct Z through to completion.

Both values agree with three native Bambu Studio captures from the same
machine, which carry [1, 17, ...] and [17, 1, ...] depending on filament
slot order.

An extruder index naming neither carriage now omits the field instead of
reaching the wire as a physical ID that identifies no nozzle. The
fixed-hotend-only path is unchanged but no longer a guess: Bambu Studio
sends no nozzle_mapping at all for such a plate, which is what Bambuddy
already did.

Reported, diagnosed and hardware-verified by @tru3l3gend, who ran the
mixed-nozzle A/B on both nozzles and captured what Bambu Studio sends
for fixed-only and mixed plates.
2026-08-11 09:37:03 +02:00
maziggy 3dc681d9f1 fix(slicer): write slice output to the source's external folder (#2810)
slice_and_persist always wrote to get_library_files_dir() while giving the
new row the source folder's id, so slicing a file on a NAS mount produced
a .gcode.3mf that showed up in the right folder in the UI and never
reached the share -- invisible from the web UI, which is why it did not
reproduce.

Resolve the destination from the target folder like uploads (#1112) and
moves already do, set is_external and store the absolute path. Collisions
uniquify to "Model (2).gcode.3mf": a 409 would throw away minutes of CPU
on a routine re-slice, and overwriting a file on someone's NAS is worse.

An external folder that cannot take the file (read-only, unreachable, not
writable) falls back to managed storage rather than discarding the slice,
and reports why on SliceResponse.external_write_fallback -- surfaced as a
warning toast. Silent fallback is what made this bug invisible.
2026-08-11 09:02:52 +02:00
maziggy b63adb8151 fix(virtual-printer): wrap the card header instead of overflowing it (#2808)
Every item in the collapsed header was flex-shrink-0, so the row was as
wide as its contents and the Card doesn't clip -- the remote-interface IP
and the enable toggle painted outside the card border. The name's
`truncate` couldn't save it: a flex item defaults to min-width:auto, so
it never shrank below its text (`flex-shrink-0 truncate` on the target
name was self-cancelling for the same reason).

Move the metadata into a flex-1 min-w-0 flex-wrap group so it wraps to a
second line, and keep the chevron, dot and toggle outside it. Wrapping
rather than truncating: the bind and remote-interface addresses are what
the page exists to show.

Needs three things at once, hence the report -- both IPs set (Bambuddy
and printer on different subnets), a target named "Printer at <ip>" from
discovery, and the 3-column card grid.

overflow-hidden is scoped to this card, not added to Card: half the cards
in the app render menus that deliberately paint outside their bounds.
2026-08-11 08:45:33 +02:00
maziggy d120a804ff fix(slicer): name the sidecar service in the update command (#2802)
The "update your sidecar image" advice told users to run a bare
`docker compose pull`. bambu-studio-api is declared with
`profiles: [bambu]`, and compose skips profile-gated services silently,
so the pull was a no-op for exactly the users the message was written
for -- and `restart: unless-stopped` kept the old container serving.
The reporter pulled, restarted, set MAX_MODEL_UPLOAD_MB and got the same
100 MB rejection, because the image never changed.

Name the service in both commands instead. Naming enables the profile
implicitly, for pull and up alike. `--profile bambu` would also work but
downloads the 220 MB Bambu image on an OrcaSlicer-only host and then
starts a sidecar the user never asked for.

Same correction in the sidecar README, the compose header and the
changelog entry, which all carried the bare form.
2026-08-11 08:35:23 +02:00
maziggy fd3f5331f3 Drop the restore's foreign keys in the database, not in the ORM metadata
Restoring a SQLite backup into PostgreSQL died part-way with

  insert or update on table "library_files" violates foreign key
  constraint "library_files_folder_id_fkey"
  DETAIL: Key (folder_id)=(1) is not present in table "library_folders".

The import recreates the schema and is supposed to create every table
without foreign keys, so the order rows arrive in cannot matter; the
constraints are added back once the data has landed. Phase 1 did that by
discarding each ForeignKeyConstraint from table.constraints before
create_all -- which only suppresses the inline REFERENCES clause.
Table.foreign_key_constraints is derived from the columns' ForeignKey
objects and was never touched, and when create_all meets a dependency
cycle it cannot sort, it falls back to emitting those tables' keys as
separate ALTER TABLE ... ADD FOREIGN KEY statements read from exactly
that property.

library_files, library_folders and print_archives form such a cycle, so
twelve constraints survived across the three of them -- measured against
a real PostgreSQL by running the old phase verbatim. The same cycle also
costs those tables their place in sorted_tables, so they were imported
alphabetically, putting library_files ahead of the library_folders rows
its folder_id references.

Phase 1 now creates the tables normally and drops every foreign key from
pg_constraint afterwards, in the same transaction, scoped to contype 'f'
in the public schema. That is indifferent to how create_all chose to
emit them, so a future cycle between other tables cannot bring this
back. Phase 3 is unchanged.

This also removes a second fault: the keys were stripped from the
process-wide Base.metadata and only restored after the drop/create
transaction, so a failure in between left the running app without them
until restart. The metadata is no longer modified at all.

Verified end to end against a real PostgreSQL -- a backup whose child
rows import before their parents restores cleanly, with all 90
constraints back afterwards. Four regression tests added.
2026-08-10 16:48:10 +02:00
maziggy 140ce1a593 Give the variant-group backfill query the nosec marker that applies
The line carried "# noqa: S608", which is ruff's flake8-bandit code -- but S is
not in ruff's select list in pyproject.toml, so ruff never ran that rule and the
marker suppressed nothing. Bandit itself only honours "# nosec", so the query
went on being reported as B608 while the line read as already handled.

The finding is a false positive. The only interpolated fragments are source_expr
and model_expr, assigned just above from a two-branch is_sqlite() check where
both branches are string literals; no caller value reaches the string. They are
JSON expressions rather than values, so a bind parameter cannot express them.

Replaces the inert marker with "# nosec B608", matching the convention already
used across the test suite, and moves the reasoning into a comment above the
statement. Bandit's medium+ count drops to 16, none of them B608.
2026-08-10 16:13:59 +02:00
maziggy 15ea11e10b Recover the preview slice from custom G-code the sidecar cannot parse
Opening the slice dialog on an unsliced project runs a preview slice purely
to ask the slicer which AMS slots the chosen plate consumes. Bambu Studio 2.8
writes {if timelapse_inline_photo} into the machine's time_lapse_gcode but
exports no definition for that variable, so the template is unresolvable the
moment it leaves Studio: an older sidecar stops with a placeholder parse error
before producing any slice_info. The preview returned nothing and the caller
fell back to guessing from painted faces, silently. On the H2D project this
was found with, the guess dropped the support material -- a whole slot off a
four-filament plate.

Retry the preview once with just the named template emptied, still on the
file's own settings. Keeping the embedded settings is what keeps the answer
honest: overriding the process preset instead discards the project's support
configuration, which loses that slot and moves used_g by up to 2x. Measured
against the same file: retry reproduces all four slots gram for gram, a
printer+process override returns three.

Only templates that cannot extrude are eligible -- a start or filament-change
template lays a prime line or purges, so emptying one would move the very
grams the preview reports, and returning nothing beats a confident wrong
number. Verified on a working H2D slice that emptying time_lapse_gcode leaves
every used_g/used_m in slice_info identical.

Match on a normalised option name: the slicer reports timelapse_gcode while
the 3MF stores time_lapse_gcode, so a literal comparison finds nothing.

Decide whether a retry applies before logging, so a slice that recovers does
not announce itself at WARNING twenty seconds before it succeeds.
2026-08-10 15:44:55 +02:00
maziggy 07495c5ae5 Updated CHANGELOG 2026-08-10 13:57:58 +02:00
MartinNYHC f982da2a49 Merge pull request #2727 from ticfinack/feature/queue-keep-warm-chamber-history
feat(queue): keep bed warm + smart chamber soak reduction for consecutive prints requiring chamber heat
2026-08-10 13:55:24 +02:00
maziggy b03603c4e2 Release the keep-warm bed on the dispatch paths that skip the rollback (#2727)
Selecting an item hands any keep-warm hold on its printer over to the preheat
pin: `_sweep_keep_warm` drops the `_keep_warm` entry and records "bed" instead,
on the promise that `_dispatch_one` will unwind it on any non-success exit. Two
of that function's exits never reached the `finally` that keeps the promise --
the claim failure returns before the `try` opens, and the vanished-row return
sat inside it but left `item_printer_id` at None, which the rollback guards on.

Either one left the bed hot with nothing tracking it. The keep-warm entry was
already gone, so the max-duration cap no longer applied and `_release_keep_warm`
had nothing to act on; if the cancelled item was that printer's last pending
one, the printer also dropped out of the candidate set, and nothing would ever
switch the bed off. Reachable whenever a cancel or delete lands between
selection and the claim -- narrow, but the outcome is exactly what the cap was
added to prevent.

`_dispatch_one` now takes the printer it was selected for. `_launch_uploads`
already had it (it stores the same value in `_inflight`), so nothing new is
plumbed, and the parameter is optional so the tests that call `_dispatch_one`
directly keep their existing behaviour.

Two pieces of hardening found while tracing that:

  * The rollback switched the bed off unconditionally, where the keep-warm
    release deliberately checks first that firmware still reports the target it
    set. It now records what it pinned and declines when someone else owns the
    bed. Every uncertain case still switches off -- no recorded target, or a
    status that cannot be read -- because a bed left hot with no owner is the
    worse failure, and this runs in a `finally` where raising would mask the
    real exception. That is why the status read is factored out into a total
    helper returning None for "no evidence" rather than 0.

  * `_apply_keep_warm` ran unguarded between selection and `_launch_uploads`, so
    anything raising there discarded the tick's selections, computed AMS
    mappings included, and on a persistent fault stopped the queue dispatching
    altogether. Wrapped, for the same reason the deficit check is: an auxiliary
    comfort feature must never wedge dispatch.

Also documents why the max-duration check sits behind the FINISH and client
guards rather than ahead of them, since the ordering looks like a hole and is
not: with no client there is no M140 to send and the elapsed check fires on the
first tick after the printer returns, and leaving FINISH means the plate was
cleared, which routes the printer to `_release_keep_warm` instead. The invariant
to preserve if that is ever reordered is that every path out of an engaged hold
ends in a bed-off.

Six tests: the handover recording its target, both early returns releasing, a
call with no printer id staying a no-op, the reassigned-bed skip, the matching
and unreadable cases switching off, and eviction on deregistration.
2026-08-10 13:54:44 +02:00
MartinNYHC 37b0e25a2b Merge branch 'dev' into feature/queue-keep-warm-chamber-history 2026-08-10 13:31:28 +02:00
maziggy 57190b10ba Stop offering server-side slicing for STEP files
The Slice action appeared on .step / .stp and the endpoint accepted the job,
but neither slicer can load one from its command line -- both answer
"Unknown file format. Input file must have .stl, .obj, .amf(.xml) extension."
So the file was read, converted and uploaded before failing as "The input
model file to the slicer can not be parsed", which reads as a corrupt model
rather than an unsupported format.

The endpoint refuses a STEP up front with a message saying to export it as
STL or 3MF, and the Slice and pipeline buttons no longer appear on one.

Open in Slicer is unchanged and still hands STEP to the desktop application,
which opens it fine -- that was always the working path. isSliceableFilename
(desktop) and isApiSliceableFilename (sidecar) are now separate predicates so
the two cannot drift back together.
2026-08-10 13:09:24 +02:00
maziggy a849759469 Explain an oversized model instead of reporting a slicer crash (#2802)
The sidecar caps model uploads and reports a rejection as a bare
HTTP 500 "File too large" -- multer's MulterError is not the sidecar's
AppError, so its handler falls through to the default status. A 500
reads as a crash inside the slicer, and the one message Bambuddy had
about request size was written for the 413 a reverse proxy sends, so
it never appeared. The reporter tried MAX_FILE_SIZE, BODY_PARSER_LIMIT
and EXPRESS_PAYLOAD_LIMIT, stopped nginx, and moved from Windows to
Docker -- none of which the sidecar reads.

Match the rejection by what it says rather than by its status, so an
installation still on an older sidecar image gets the same explanation.
The 500 match is strict -- the body must be only multer's message --
because a genuine CLI failure is also a 500 and has to keep reaching
the embedded-settings fallback. Old images are told to update, since
they have no setting to change; current ones are told which one to set.

Raising SlicerInputError rather than SlicerApiServerError is also what
skips the fallback retry, which had been re-uploading the identical
oversized file after a second 25-second 3MF conversion.

Log the model size on every slice. Nothing recorded it, so a support
package from a slice that died on an upload cap looked exactly like one
that died on a bad profile, and this had to be sized by hand.

Fall back to the exception class name when a transport error stringifies
empty -- three lines of the reporter's log read "Slicer sidecar
unreachable: " and stopped there.

Needs a sidecar image update to take full effect; MAX_MODEL_UPLOAD_MB is
documented in slicer-api/.env.example.
2026-08-10 12:18:29 +02:00
maziggy 7c117dc6bc Let API clients resolve user ids to names (#1894)
Archives, the queue and statistics report ownership as a numeric
created_by_id, and statistics accept it as a filter, but nothing let an
API key discover whose id was whose -- the only user listing returns
emails, roles, group membership and full permission sets, so it is
administrative and rejects keys.

Add GET /users/slim returning id + username only, gated on a new
users:read_slim permission mapped to can_read_status. That grants no
data a key could not already reach: for API-keyed requests the
permission deps return None as current_user, so the stats:filter_by_user
guard short-circuits and ?created_by_id=N is already honoured for every
N. What was missing was the ability to address the filter, not
permission to use it. The full listing stays unmapped = admin-only.

Also fix /auth/me, which answered an API key with a synthetic
administrator: id 0, role admin, is_admin true and every permission in
the enum. A key cannot reach an administrative route at all, so clients
building their UI from that response rendered actions that 403 on use.
It now reports the key owner's identity, is_admin false, and the
permissions the key's scopes actually admit. Ownerless legacy keys keep
id 0 but no longer claim admin.

---

Source user names from the slim listing where only names are needed (#1894)

Stats filter-by-user, the Archives print log filter, the File Manager
username autocomplete, the camera-token owner column and the Finance
member picker all render nothing but a username, but all of them read
the full user listing, which is gated on the admin-level users:read.
An operator granted stats:filter_by_user but not users:read got an
empty filter with no indication why.

Point them at /users/slim under a separate react-query key, since the
full listing shares the 'users' key and the two shapes would clobber
each other in the cache.
2026-08-10 11:43:23 +02:00
maziggy 71709c16aa Stop tearing down AMS drying for a print that cannot start (#2801)
A printer in FINISH with an unacknowledged plate and something pending in
its queue stopped and restarted drying once per scheduler tick, for as
long as the plate stayed unacknowledged. The reporter's Home Assistant
history recorded about 2000 state changes over ten days. No cycle ever
ran long enough to remove moisture, and cycles the user had started by
hand on other AMS units of the same printer were torn down with it.

Two concerns had become tangled. Plate-clear answers "is the bed ready
for the next job" and says nothing about whether the AMS may heat. The
gap between a finished print and the acknowledgment is when drying is
most useful -- the printer is free and nobody is waiting on it -- and
leaving the plate unacknowledged is also how people hold the queue by
hand, so the hold was costing them the drying it should have enabled.

Four faults, all in print_scheduler.

The "print takes priority" stop sat inside the not-idle branch. Drying is
not one of the things _is_printer_idle looks at, so stopping a cycle can
never turn a non-idle printer into an idle one: the stop was futile every
time it fired, and never fired on the dispatches where it was supposed to
mean something. It now runs when the printer is actually dispatchable,
and only where the model cannot dry through a print -- #2758 settled that
capable hardware should keep its cycle.

mid_print was inferred from busy_printers, which means "the queue could
not dispatch here this pass", not "is printing". A plate-held printer was
therefore treated as printing: the mid-print spool-protection cap
silently lowered its drying temperature, the cycle was logged as
(mid-print) in FINISH, and it bypassed the very gate meant to hold it.
busy_printers keeps its dispatch role; auto-drying now gets a narrow set
snapshotted before the item loop -- running, held post-dispatch, or
mid-upload -- and mid_print comes from the printer's own state. The
interlock comment at the seed already documented this hazard and worked
around it by staying out of the set; this generalises that instead of
adding a third special case. The other call site was already passing the
narrow set, so the wide one was the inconsistency.

_stop_drying sent a stop to every AMS reporting dry_time > 0. One
auto-dried unit was enough to kill a manual cycle on a different unit of
the same printer, contradicting the contract _sync_drying_state already
documents: the entry gate only knows about cycles Bambuddy began, so the
action must not reach past them. Consequence worth stating -- after a
restart Bambuddy cannot prove a running cycle is its own, so it leaves it
alone rather than risk stopping somebody's manual dry.

Fourth, and the reason #2770's guard did not catch this: a reading at or
below the threshold popped the unit's whole entry, ended_at included, so
the 30-minute re-arm cooldown went with it. An AMS reads higher warm than
cool, which is #2770's own finding, so a unit a point or two above the
threshold dipped below it as it cooled, wiped its history, and re-armed
immediately. Lifting a suspension now clears the judgement and keeps the
clock.

queue_drying_block changes behaviour as a result. It previously had no
effect on dispatch at all -- both branches skipped anyway, and it only
decided whether drying was needlessly killed. With the stop on the
dispatch path it now does what it says: a queued print waits for a
running cycle. Off by default.

Reported by @superflyer11, who traced both defects to the line and
brought ten days of external sensor history to date the cadence.
2026-08-10 09:31:25 +02:00
maziggy ec26cba927 Resolve the H2C rack nozzle at dispatch instead of letting firmware pick (#2800)
An H2C ran its startup clean and bed levelling on one hotend, switched,
and then printed several millimetres above the plate. The same job from
Bambu Studio was fine.

The H2C is the only model that mounts its nozzle from a rack of six, and
a print command names that nozzle by physical rack position -- the
firmware reports those as 16 to 21 -- not by the extruder index, 0 or 1,
every other dual-nozzle printer uses. Bambuddy only ever had a rack
position when a job arrived through the Virtual Printer, which captures
Bambu Studio's pick and replays it (#1780). Anything queued from the
library, an archive, the webhook or a slicer pipeline carried none, so
the field was omitted and the firmware chose -- and its choice need not
match what the file was sliced for.

The scheduler now derives the per-slot extruder assignment from the file
it is about to send, and the MQTT layer resolves it against the rack
position the printer reports live. Both are needed: the file knows which
side a slot prints from, only the printer knows which hotend is in the
carriage, and it can be swapped from the touchscreen between queueing a
job and printing it.

Derived at dispatch rather than at creation because that is the first
point knowing both the real printer and the real file -- an item can be
created unassigned, reassigned later, or have its file swapped for a
G-code-injected copy. One call therefore covers the print dialog, bulk
library adds, the webhook and pipeline runs, and no column is needed.

extract_nozzle_mapping_from_3mf is deliberately untouched. Its output
feeds the AMS matcher, where nozzle_id is compared against a tray's
extruder_id as a hard filter, and physical_extruder_map is what makes
that comparison correct -- on an H2D it is [1, 0] and flips the two.
Dropping the translation to suit the rack would send every dual-nozzle
AMS match to the wrong extruder. The dense per-slot form is a separate
function reusing the same output.

Nothing here can fail a dispatch. The command is built and published with
no exception handler above it, and the queue item is already committed as
printing by then, so a bad input has to degrade to "firmware picks"
rather than wedge the item. resolve_rack_nozzle_mapping validates every
input and raises nothing; an unresolvable mapping, an unparseable value
or an unknown rack position all omit the field, which is the behaviour
that existed before. Slot IDs are bounded before the dense list is built:
they come from the file, and one declaring filament id="50000000" would
otherwise allocate a fifty-million-entry list on the dispatch path.

Two things are not guessed. A job printing only from the fixed hotend is
still left to the firmware, because that nozzle's physical ID is not
confirmed by a known-good capture. And the rack is taken to feed extruder
0 from a single hardware observation -- if that is flipped, a one-sided
job matches nothing and falls back to the old behaviour, so only a job
using both nozzles at once could be harmed, which is what a second
capture needs to confirm.

Confined to the H2C throughout. Building the print command for 21 model
spellings with and without the new argument changes exactly three of them
-- H2C, O1C and O1C2. The other 18, including H2D and X2D, are identical.

Reported by @tru3l3gend, who diagnosed it on real hardware against a
working Bambu Studio dispatch, established the rack ID range and supplied
a patch.
2026-08-10 08:54:44 +02:00
Thomas Scott Williams f86f5a7c34 feat(queue): keep the chamber warm between prints and skip redundant soak
Back-to-back prints in chamber-heated materials (ASA, ABS, PA, PC) each paid
a full heat-soak from cold, even when the print that just finished had left
the chamber at temperature. Two changes remove that cost.

Keep bed warm between prints
  While a printer sits in FINISH awaiting plate-clear and the next queued item
  needs chamber heat, hold the bed hot so the chamber does not cool during the
  bed-clearing window. The bed is the chamber's heating element here, not a
  print surface, so the hold runs at the new `queue_keep_warm_bed_temp`
  (default 90C, which also satisfies bed-threshold-linked aftermarket chamber
  heaters), raised to the item's own bed temperature when that is higher.

  Gated on `queue_keep_bed_warm` AND `require_plate_clear` AND
  `preheat_enabled`, all re-checked in the backend so a stale UI cannot leave
  the feature running. `queue_keep_warm_max_minutes` (default 120) bounds the
  hold: when it elapses the bed is switched off and the hold latches until the
  printer is next a candidate, so a plate nobody clears cannot leave the bed
  hot indefinitely. The hold is also released when the item is deleted, the
  queue empties, or a gate is toggled off mid-hold, and never when firmware
  reports a target other than the one it set — a temperature the user or a
  print changed is left alone. Publishing is idempotent.

Smart soak reduction from chamber history
  The scheduler samples each connected printer's chamber temperature every tick
  into a 2h rolling history. Preheat credits time the chamber has already spent
  at temperature against the configured soak, shortening or skipping it.

  Credit starts no earlier than the newest sample, the most recent unbroken run
  of samples, or the end of the last real dip below target. A dip only counts
  once it outlasts a grace period: an enclosed chamber cannot lose and regain
  several degrees quickly (measured on an X1C, cooling from 55C to below 48C
  takes 23-73 minutes, ~0.2 C/min), so a brief low reading is a door opening or
  sensor noise rather than lost soak — and a plate swap, which is exactly when
  keep-warm runs, produces one. A stale history credits nothing: at that
  cooling rate the chamber can cross the threshold unobserved, so the full soak
  runs instead.

Three supporting changes to preheat itself:

  * Cancelling or deleting a queued item now stops a preheat already running
    for it. Those routes only write `status` to the database, which a dispatch
    coroutine parked in `asyncio.sleep` cannot observe, so the heaters ran for
    the rest of max_wait + soak — 45 minutes at the default settings — and the
    printer stayed in `busy_printers`, blocking every other queued item behind
    a print that was not happening. The routes now signal the scheduler
    directly, and the stage sleeps in slices so it notices promptly and
    abandons the dispatch, letting the existing rollback shut the heaters off.

  * A chamber-heated print whose slicer metadata carries no bed temperature
    (common for Orca-exported 3MFs) used to skip preheat entirely and start
    with a cold chamber. It now heats the bed to `queue_keep_warm_bed_temp`.
    A parsed bed temperature still wins, and a print with no chamber
    requirement still skips — no bed temperature is invented for the print
    itself. Preheat's bed target is transient regardless: the print's own
    gcode issues its M140/M190 at start.

  * Preheat records which commands it sent (bed, chamber, airduct) and unwinds
    them if the dispatch aborts before the print starts — a failed upload, a
    cancelled item, an exception — instead of leaving the printer heating for
    a job that is not happening.
2026-08-09 17:07:34 -04:00
maziggy 08df660f6c Replace the embedded G-code viewer with the slicer's own renderer
Sliced files previewed through a vendored copy of PrettyGCode in an
iframe. It drew each move as a screen-space line -- a line has no
thickness in the scene, so it cannot occlude the layer behind it, which
is why prints came out stringy and shimmered where layers crossed. Being
a separate app in a frame, it could be neither themed nor translated, and
carried its own machinery for detecting a proxy refusing the embed.

Now built on libvgcode, the renderer OrcaSlicer draws its own preview
with, vendored from three-slicer (AGPL, same as us). It takes the THREE
namespace as an argument and imports nothing, so it runs on our 0.181
rather than the 0.160 its package pins.

The parser is ours; upstream renders its own kernel's output and ships no
G-code parser at all. Two things it has to get right, both found by
checking a real plate rather than assuming:

- BambuStudio does not use the OrcaSlicer/PrusaSlicer annotations. It
  writes "; FEATURE:", "; LINE_WIDTH:", "; CHANGE_LAYER" and
  "; Z_HEIGHT:", not ";TYPE:", ";WIDTH:" and ";LAYER_CHANGE". Reading
  only the latter showed a 52-layer print as 23,165 layers in one colour,
  because with no layer marker recognised every travel Z-hop split a
  layer and every segment took the fallback feature.
- It emits a tenth of its moves as G2/G3 arcs -- 706 extruding ones in a
  single plate. Ignoring them punched holes through curved walls and tree
  supports. Arcs with no X/Y are the helical travel lift and lay down
  nothing, so they interpolate as travels.

Four colour modes: filament (default, from the AMS slots the file was
sliced with), feature, layer height, line width. Speed, fan and
temperature are deliberately absent -- upstream derives those from
settings rather than the toolpath, and guesses dressed as measurements
are worse than an honest omission. The parser now carries the data to do
them properly later.

Legend entries are switches. Hiding removes the records before the mesh
is built rather than recolouring them: the shader packs colour into a
single float with no alpha, so there is no transparent to set, and
removal is the useful behaviour anyway -- a hidden support stops
occluding what it covered.

The scene is built once and only the toolpath rebuilds. Doing otherwise
constructed a new WebGLRenderer on every render, because the buildVolume
default is an object literal and so a fresh identity each time; browsers
cap live WebGL contexts and drop the oldest, which blanked the canvas
after a few interactions.

utils/framing.ts goes with the iframe, along with six now-orphaned
strings in all 13 locales. src/lib/vendor is excluded from eslint --
acting on findings in vendored code makes it impossible to re-copy on the
next upstream release.
2026-08-09 14:10:18 +02:00
maziggy 2d584e02a4 Render the previews properly instead of sketching them
Model preview
-------------
The camera distance came from `maxDim * 1.8`, which accounts for neither
the camera's field of view nor the viewport's aspect ratio, so a tall
narrow panel was framed as though it were square -- the model sat in the
middle with a screenful of dead space above it. Solved from the bounding
sphere against both fields of view instead, so it fills the frame at any
panel shape. Near/far now scale to the subject rather than staying at the
0.1/10000 defaults.

Lighting was two directional lamps over 0.6 flat ambient on a Phong
material: every surface facing the same way got an identical colour,
which is what flattened models into silhouettes. Now a MeshStandard
material lit by a generated RoomEnvironment through PMREMGenerator, with
ACES tone mapping so the lit side of a saturated filament colour doesn't
clip to white and drain the hue.

Added a contact shadow. Two things would have made it silently draw
nothing: the build plate is an unlit MeshBasicMaterial and cannot receive
shadows, so the catcher is a separate ShadowMaterial plane; and three's
default directional shadow camera is a +/-5 unit box, which nothing on a
256mm bed falls inside.

The PMREM render target is disposed on unmount -- it is GPU memory the
collector cannot reclaim, and this viewer is opened and closed repeatedly
from the file manager. Device pixel ratio is capped at 2; a 3x phone
screen was quadrupling fragment load for no visible gain.

G-code preview
--------------
Switched gcode-preview from `lineWidth: 2` to `renderTubes`. A 2px
screen-space line has no thickness in the scene, so it cannot occlude the
layer behind it -- hence the stringy surface and the shimmer where layers
overlap. Tubes are built from real extrusion width and height, so the
print occludes itself.

The flag is marked experimental upstream, and the 0.42 extrusion width is
a hardcoded default that is right for a 0.4 nozzle and wrong for a 0.6.
Both are worth revisiting if this holds up in use.

Modal
-----
Removed the G-code tab. G-code has its own full-page viewer, and a
preview of a model is a different question from a preview of a print.
That left dead weight behind it: the render branch, the GcodeViewer
import, the has_gcode capability (still computed, never read), the Code2
icon, and two orphaned strings in all 13 locales. One test was repurposed
to assert the tab is absent so it cannot creep back; two others only
exercised that tab's disabled state and went with it.
2026-08-09 12:00:11 +02:00
maziggy b6027b7138 Say why a preset's values are unavailable, not just that they are
The settings panel collapsed four causes into one message -- "the picked
preset's own values could not be read" -- with no indication of what to do
about it.

The overwhelmingly common cause has an obvious fix, and it isn't an edge
case: an install pulls its sidecar as SIDECAR_TAG:-latest regardless of
which Bambuddy channel it is on, so a current Bambuddy talking to a
sidecar that predates POST /profiles/resolve is the normal state, not a
misconfiguration. Those users would have seen an amber warning on every
slice with nothing pointing at the sidecar image.

resolve_profile now returns ResolvedProfile(values, reason) instead of
None for everything, the route passes the reason through, and the panel
picks its message from it:

  sidecar_outdated     -> name the fix: update the sidecar image
  sidecar_unavailable  -> the sidecar did not answer
  not_configured       -> no sidecar is configured
  preset_unresolved    -> the previous generic wording

A request that fails outright maps to sidecar_unavailable, since a
backend we cannot reach and a sidecar that will not answer are the same
thing from the dialog.

Every variant still ends with "anything you don't change still uses the
preset" -- that reassurance is the point of the notice, and it is true
whichever way the lookup failed.

Tests pin the distinction rather than just the happy path: a 404 and a
500 must produce different reasons, and each panel case asserts both that
its own message appears and that the "update the sidecar image" line does
not leak into the others.
2026-08-09 11:25:09 +02:00
maziggy f421bb8160 Show the picked preset's real values in the process-settings panel
The panel baselined every field on the option schema's compiled-in
defaults, so a preset setting a 0.42mm line width displayed 0 -- the C++
default meaning "derive from the nozzle". Every field was affected; the
Line width group just made it obvious.

Bambuddy cannot answer this itself. A standard-tier pick is only an
{inherits: ...} stub on our side, and local/cloud presets are deltas whose
remainder lives in the profile tree bundled inside the running sidecar.
The values now come from the sidecar's POST /profiles/resolve, which runs
the same resolver /slice does against the same profiles, so what the panel
shows cannot disagree with what a slice produces. Deliberately not the
local orca_profiles resolver: it walks OrcaSlicer's published tree, which
can differ from the image actually installed.

An untouched field shows the preset's value and reverting returns to it.
isModified compares against that baseline too, so fields the preset moved
off the C++ default are no longer flagged as user edits, and values nobody
typed are no longer sent. When the values can't be read -- sidecar offline
or older than the endpoint -- the panel falls back to schema defaults and
says so rather than presenting them as the preset's.

Row layout, from screenshots:

- The control column is anchored to the right edge at a fixed width. It
  had been packed left after a fixed label column, leaving the values
  stranded mid-container with dead space beside them.
- Units are no longer truncated to "mm o...". The cap fitted the common
  "mm" but not "mm or %" or "mm/s² or %".
- The "from file" tick moved ahead of the control it qualifies; it used to
  sit past the unit at the row's right edge, reading as unrelated.

Both the unit and the control keep fixed widths, and the tick's slot is
reserved on rows without one -- sizing any of them to content makes each
row's input land at a different x and the column comes out ragged.

Also fixes a field that could not be cleared: emptying a free-text input
dropped the key, so it snapped back to the baseline and retyping appended
to it ("0.42" + "0.5" = "0.420.5"). The number branch was fixed earlier;
the text branch -- coFloatOrPercent, coString, the vector types -- was
not, and the regression test used a number input so it never caught it.

Requires a sidecar built from orca-slicer-api 4b664b7 or later. Older
images 404 the endpoint, which is handled as the fallback above.
2026-08-09 11:02:03 +02:00
maziggy c384911f7c Sync 2026-08-09 10:00:53 +02:00
maziggy 2368445378 Changed layout 2026-08-08 16:51:19 +02:00
maziggy f0500578bd Edit the full print-parameter set from the slice dialog
Slicing from Bambuddy meant taking a process preset as-is; any change
meant a round trip through Bambu Studio. The slice dialog now carries
OrcaSlicer's full process tree -- pages, groups, labels, tooltips,
ranges and defaults extracted from the slicer's own sources.

Enable/disable rules are evaluated from the slicer's own enable_if
expressions via a recursive-descent interpreter (no eval, CSP), with
enum comparisons validated against each option's declared values.
Anything undecidable leaves the field editable rather than greyed.

Overrides apply after the source's support config (#1881) and the
designer's carried tweaks (#2622), so an explicit choice always wins;
an untouched panel sends the same request as before.

Adds slice_engine as a separate setting from preferred_slicer -- where
slicing runs is a different axis from which binary the sidecar drives.
Only the sidecar engine is registered, so no picker renders yet.
2026-08-08 16:33:41 +02:00
maziggy 1748d7cefa Updated .github/workflows/windows-installer.yml 2026-08-08 14:12:13 +02:00
maziggy 287ee05596 Removed unused test backend/tests/unit/test_finance_table_migration.py 2026-08-08 13:37:52 +02:00
maziggy c8e5ecc23a chore(deps): clear every npm audit and pip-audit finding
Frontend:
- react-router/-dom 7.18.1 -> 7.18.2. The RSC-mode CSRF advisory was carried
  as a documented exception in the audit gate because its only fix was the
  8.3.0 major; upstream backported it, so the exemption lapsed on its own --
  an entry only holds while fixAvailable.isSemVerMajor is true. The allowlist
  is now empty; the machinery stays for the next one.
- dompurify 3.4.12 -> 3.4.13. Ships in the app, but the path is unreachable:
  no hooks registered, IN_PLACE never used.
- js-yaml override ^4.3.0 -> ^5.2.3 (fix not backported below 5.x, so a
  major) and nanoid override ^3.3.18. Both dev-only, via eslint and postcss.
  eslintrc calls only load(), on the legacy .eslintrc.yml path this repo does
  not use; eslint, vite build and 2861 frontend tests pass on it.

Backend:
- cryptography >=48.0.1 -> >=50.0.0, aiohttp >=3.14.0 -> >=3.14.3, pyopenssl
  >=26.3.0 -> >=26.4.0. CI resolves from scratch and was already installing
  the fixed releases; the floors cover the case CI does not, an existing venv
  where >= is satisfied and `pip install -r` upgrades nothing. pyOpenSSL has
  to move with cryptography -- each release caps it to a narrow window, so a
  stale pyOpenSSL pins cryptography below its own fix line.
2026-08-08 13:20:18 +02:00
maziggy 73eec29358 Updated BACKERS 2026-08-08 12:45:07 +02:00
maziggy 328bac450a Stop auto-drying re-arming into a threshold it can never reach (#2770)
An H2D armed five 12-hour drying cycles inside four hours, one of them six
seconds after the previous one ended, and none ran more than a couple of
hours.

Two things combine. The firmware ends a cycle when it decides the filament
is dry rather than when the clock runs out, and reports no fault doing it --
across this printer's history the run length tracks how wet the spools were,
from nearly the full 12 hours starting at 32% down to minutes once the unit
sat at 10-13%. That part is the AMS doing its job.

The loop is ours. An AMS reports higher relative humidity while it is warm
than once it has cooled: the same unit read 10-13% cold and 15-20% through
every cycle. With the threshold at 14% the reading at the moment a cycle
ended was always still above it, so the next 30-second pass armed another
12-hour cycle. Nothing counted, nothing waited, and it only stopped when the
box finally cooled enough to read 13%.

Auto-drying now waits 30 minutes after a cycle ends before arming another on
the same unit, and gives up on a unit after two consecutive cycles that
bring the reading no lower -- logging why and sending a new notification,
on by default because it reports that Bambuddy has stopped acting. Progress
is judged against the lowest reading any cycle on that unit has ended at,
not against the threshold, so a genuinely wet spool in a humid room coming
down 40-37-35 keeps drying however far it still is from the target;
comparing against the best so far rather than the previous end stops a
sensor wobbling by one point reading as progress every other cycle. The
suspension lifts by itself once the reading falls below the threshold.

Neither guard can stop a running cycle, and a cycle Bambuddy cut short for a
print, or that the user stopped by hand, is not counted against the unit --
so a farm that dries between queue jobs is unaffected. The threshold field
now warns below 20%, and every cycle end logs the unit's temperature and
humidity, which is what made this diagnosable.

The same bundle showed unrelated tasks failing with "database is locked",
each inside a 30.000-second Discord connect timeout. Alarms are raised from
inside the loop that records sensor history, at a point where the new rows
are added but not committed; the first read in the notification path flushed
them to satisfy itself, opening a write transaction, and the provider was
then contacted over the network with that transaction still open. SQLite
allows one writer and 30 seconds outlives the 15-second busy timeout, so
every other write in that window failed. The two reads that run before a
provider is contacted no longer flush the caller's pending work, and the
connect timeout is 5 seconds rather than 30 -- the body keeps the full 30,
so image uploads on a slow uplink are unaffected. SQLite only; Postgres has
no single-writer limit.
2026-08-08 12:40:17 +02:00
maziggy 04009a5c6a Post work PR #1448 2026-08-08 11:42:44 +02:00
MartinNYHC 3cd538acb9 Merge pull request #1448 from behrinml/feature/billing
[Feature] Cost Centers and Billing Functionality
2026-08-08 11:20:44 +02:00
maziggy bf525661d3 Three fixes on top of the billing branch, all found by running the suite against
both dialects rather than one.

Postgres upgrades never got as far as the finance schema.

  database.py added on_billing_charge_failed with BOOLEAN DEFAULT 1. The 1 is a
  SQLite-ism; Postgres answers DatatypeMismatchError, and _safe_execute
  deliberately re-raises anything that is not an idempotency error, so
  run_migrations died there and rolled the whole transaction back. No finance
  tables, no columns, and the app does not start. Six lines above, the same
  change gets is_voided right with an is_sqlite() branch, so this was an
  oversight rather than a decision. Now branched the same way.

  This also explains the four test_security.py::TestBackupKeyFiles failures
  reporting "column print_archives.cost_center_id does not exist". That column's
  migration exists and works -- it simply never ran, because every startup
  aborted before committing. Reproduced against Postgres 16 by building a
  pre-billing schema from dev and upgrading over it: fails without this,
  completes with it, and re-running the migrations or starting from an empty
  database are both clean.

test_billing_run_id_migration.py failed on any Postgres-configured checkout.

  It builds its own SQLite engine, but run_migrations branches on the global
  dialect rather than the connection in hand, so on a box whose DATABASE_URL
  points at Postgres it emitted md5(random()::text) and btrim() into SQLite.
  Given the same fixture test_ldap_migration.py already carries for exactly this
  reason. The suite now agrees across dialects -- 9190 passed either way, where
  it used to be 9184 on one and 9183 on the other.

The kill switch could not tell a print Bambuddy started from one it merely
watched.

  Authorization fell back to a print_archives row in status="printing" matched on
  subtask_id. But on_print_start archives every print it observes, including ones
  started from Bambu Studio or Handy, and stamps them with the same status and
  subtask_id -- the code says as much where it notes "a print Bambuddy didn't
  dispatch". So a foreign print became authorized the moment its 3MF finished
  downloading, and _active_prints was rehydrated from it, making that permanent.
  The switch fired only inside the download race, and never afterwards. Neither
  test caught it: one stubs the authorization call to False, the other stubs the
  query to return an archive, so the real lookup was never exercised against a
  foreign print.

  Authorization now requires a marker Bambuddy writes itself: billing_run_id,
  minted per dispatch in the scheduler, or created_by_id carried over from the
  queue item. Failing that, it looks for a queue row in status="printing" on that
  printer -- committed before the MQTT send, and the only durable trace a
  library-file dispatch leaves, since those have no archive at send time and the
  row created for them moments later carries neither marker. That row cannot be
  tied to a subtask_id, so it defers rather than authorizes.

  Deferring also closes a false positive the previous version shared: a restart
  in the window between the send and the download left no archive at all, and a
  Bambuddy print was stopped as unauthorized. Stopping a print is irreversible
  and declining to act costs a log line, so ambiguity resolves that way.

  Tests cover an unmarked archive not being authorization and not entering
  _active_prints, either marker alone authorizing and rehydrating the fast path
  without touching the queue, an unmarked archive with a live dispatch deferring,
  a dispatch not yet archived deferring, and nothing at all being unauthorized.
2026-08-08 11:19:33 +02:00
MartinNYHC 406cf71149 Merge branch 'dev' into feature/billing 2026-08-08 10:56:09 +02:00
maziggy 595dc5844a Say which header blocked the 3D preview, instead of leaving the browser's page (#2787)
A reporter uploaded an STL, sliced it in Bambuddy, and got a frowny icon and
"<hostname> refused to connect" when previewing the sliced file -- while the
STL's own preview worked. That is Chrome's ERR_BLOCKED_BY_RESPONSE page, drawn
inside our layout shell, and the split between the two previews is where the
cause is: an STL or source 3MF renders in the page, a sliced file opens the
embedded G-code viewer, which is the only thing in Bambuddy that frames a
Bambuddy page (FileManagerPage.tsx:2472, GCodeViewerPage.tsx:47).

Our headers permit that frame -- frame-ancestors 'self' plus SAMEORIGIN on
everything under /gcode-viewer (main.py:7709) -- and the frame is same-origin,
so a refusal means a stricter header was added after we replied: a reverse
proxy, a security add-on, an auth gateway. None of which the user could see.
The browser drew its own page and nothing said what was refused, by whom, or
that the viewer opens perfectly well in a tab.

The frame cannot report this itself. A frame blocked by X-Frame-Options or
frame-ancestors still fires onLoad -- the browser commits an error document --
so there is no failure to catch. The page now asks for the same URL directly:
same-origin, so every response header is readable, and it goes through whatever
proxy the browser reaches Bambuddy by.

findFramingRefusal reads the verdict the way a browser does. frame-ancestors
wins outright when present, because CSP requires X-Frame-Options to be ignored
in that case -- reading both would blame a proxy-added DENY the browser never
consulted. Multiple CSP headers are intersected and fetch joins them into one
comma-separated string, so every frame-ancestors occurrence has to permit us,
not just the first; that is the shape a proxy appending its own policy to ours
actually takes. Failing that, a legacy header that is anything other than a
single SAMEORIGIN refuses us, including the conflicting "SAMEORIGIN, DENY" that
appears when a second copy is appended.

On refusal the frame is replaced with the header named verbatim, so an operator
can go and find the rule in their proxy config, and a link that opens the viewer
in its own tab -- a top-level page, which no framing header applies to. A
non-200 is reported the same way rather than as raw {"detail":"Not Found"}
inside the frame, which the startup-time warning at main.py:8120 already calls
out as easy to miss. A probe that cannot reach a verdict changes nothing: the
iframe stays, because guessing at a cause we cannot see is worse than the
browser's own page.

The working case is unaffected -- the iframe renders immediately as before and
the probe only ever replaces it.
2026-08-08 10:53:54 +02:00
maziggy f5fbae45a3 Housekeeping 2026-08-08 10:20:57 +02:00
maziggy bbcb7e54db Merge branch 'worktree-fix-2791-cursor-pointer' into dev 2026-08-08 10:16:15 +02:00
maziggy 3f218729b9 . 2026-08-08 10:14:37 +02:00
maziggy 82d6d98266 Restore the pointer cursor on interactive controls (#2791)
Hovering most of Bambuddy gave an arrow rather than a hand. Not everywhere,
which is what made it read as sloppiness rather than a bug: the update pill was
inert while the buttons beside it were fine, a bed or nozzle tile responded but
the history-graph button in its corner did not, and dropdowns went either way
with no pattern behind it.

The pattern was there. Tailwind v3's Preflight set `button { cursor: pointer }`.
v4 dropped it to match the browser default, which for a button is `default`.
Bambuddy has been on v4 since the frontend was built, and `src/index.css` never
had a base layer restoring it, so a button only looked clickable where someone
had written `cursor-pointer` by hand. 15 of 934 had. 0 of 149 selects, and 19
of 130 checkbox/radio inputs. The 233 ad-hoc `cursor-pointer` usages are why it
looked arbitrary instead of uniformly broken.

One `@layer base` rule now covers button, select, checkbox, radio, summary and
[role=button]. base sits below utilities, so `cursor-not-allowed` and the
`disabled:cursor-*` variants still win; the `:not(:disabled)` guard catches the
disabled controls that carry no such utility. Verified against the built bundle
rather than the source -- the rule lands inside @layer base, and
`.cursor-not-allowed` is emitted after it.

Click-outside backdrops are deliberately excluded. 90 of the 96 remaining
onClick divs are `fixed inset-0` overlays; a full-screen sheet advertising
itself as a button is worse than one that says nothing. Of the rest, 50 are
stopPropagation wrappers and 3 are the temperature tiles, which already set the
cursor through `statusControlClass` -- which is exactly why those tiles worked
while the button nested inside them did not. That left two real ones: Card, now
conditional on an onClick actually being passed, and the queue card, whose
existing `sm:cursor-default` kept the desktop intent.

Separately, from the same report. FilamentHoverCard draws the slot menu twice,
and the two paths had drifted into opposite orders: Configure above Assign Spool
on an empty slot, the reverse on a filled one, so the menu reshuffled itself
depending on whether the slot held filament. Both now lead with the spool
action. Tests assert the order on each path, so one can no longer move without
the other -- checked by reinstating the old order and confirming the empty-slot
test fails.

Those buttons also used justify-center, which centred each label independently
and left the icons in a ragged column; they are justify-start now. Their hover
was a 10% opacity step that was very hard to see, now 20%. And the favourites
star previews yellow on hover, suppressed when the user lacks archives:update.
2026-08-08 10:06:45 +02:00