Commit Graph
3942 Commits
Author SHA1 Message Date
maziggy 558159c95c 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-15 14:56:41 +02:00
maziggy 03ec2aef4d 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-15 14:56:24 +02:00
maziggy 3ac289fccb 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-15 14:56:04 +02:00
maziggy 0cdc9944a4 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-15 14:55:45 +02:00
maziggy 162f52422f Updated CONTRIBUTING.md 2026-08-15 14:55:28 +02:00
maziggy 9a8c47ee4e 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-15 14:55:13 +02:00
maziggy 9b2bd911d8 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-15 14:54:56 +02:00
maziggy 26e49e76dd 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-15 14:54:39 +02:00
maziggy b5330b2f88 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-15 14:54:22 +02:00
maziggy 4fc72c0ddb 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-15 14:54:04 +02:00
maziggy c0c0c611b8 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-15 14:53:46 +02:00
maziggy 6225f8fbdd 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-15 14:53:30 +02:00
maziggy 7bd85692f5 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-15 14:53:13 +02:00
maziggy 49dffd32fd 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-15 14:52:55 +02:00
maziggy 0a0c31cca0 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-15 14:52:25 +02:00
maziggy 60f8529a3c 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-15 14:52:07 +02:00
maziggy 61b2a20f38 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-15 14:51:28 +02:00
maziggy 270990faf4 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-15 14:51:12 +02:00
maziggy 42946df211 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-15 14:50:49 +02:00
maziggy 228ef36a84 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-15 14:50:16 +02:00
maziggy f648439536 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-15 14:49:43 +02:00
maziggy 493dadb1a8 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-15 14:49:03 +02:00
maziggy d334a9f691 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-15 14:48:35 +02:00
maziggy 84cd400685 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-15 14:48:13 +02:00
maziggy eda86e5198 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-15 14:47:46 +02:00
maziggy f67f32133b Sync 2026-08-15 14:47:29 +02:00
maziggy 13bcf52f22 Changed layout 2026-08-15 14:47:14 +02:00
maziggy a7324ad846 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-15 14:46:56 +02:00
maziggy a2b250b936 Updated .github/workflows/windows-installer.yml 2026-08-15 14:46:36 +02:00
maziggy 124627d0e5 Removed unused test backend/tests/unit/test_finance_table_migration.py 2026-08-15 14:46:25 +02:00
maziggy d417724d0c 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-15 14:46:09 +02:00
maziggy 421e533513 Updated BACKERS 2026-08-15 14:45:53 +02:00
maziggy 7d137e312b 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-15 14:45:16 +02:00
maziggy b15340c99b 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-15 14:44:22 +02:00
maziggy 69b1782a00 Housekeeping 2026-08-15 14:44:00 +02:00
maziggy d75a59252e Merge branch 'worktree-fix-2791-cursor-pointer' into dev 2026-08-15 14:43:46 +02:00
maziggy af8f423e9d . 2026-08-15 14:43:28 +02:00
maziggy 50ec1872cb 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-15 14:43:04 +02:00
maziggy 5c1c0914f4 Explain Bambu Cloud's CAPTCHA challenge instead of repeating it (#2790)
A reporter tried to connect to Bambu Cloud and got "We need you to confirm you
    are not a robot" as an error toast, with no CAPTCHA anywhere to answer and
    nothing to click. That sentence is Bambu's, not ours. Their anti-abuse layer had
    flagged the network and was answering the sign-in with HTTP 418 and a challenge
    body: {"captchaId": "...", "error": "We need you to confirm you are not a
    robot"}.

    Bambuddy had no idea what that was. The reply is well-formed JSON, so
    _detect_cloudflare_challenge -- which triggers on an unparseable body, CF
    markers, 403+cf-mitigated or 503+cf-ray -- never fired on it, and login_request
    fell through to its generic error path, which lifts data["message"] or
    data["error"] out and hands it to the UI verbatim. The user was left to conclude
    their password was wrong or that Bambuddy was broken. Four sign-in attempts
    inside eighteen seconds appear in their log, each one more evidence for the
    thing that had flagged them.

    is_captcha_challenge matches on the 418 status plus a challenge marker in the
    body -- captchaId is the reliable one, the wording is matched too because Bambu
    has shipped it under more than one phrasing. A bare 418 with no marker is
    has shipped it under more than one phrasing. A bare 418 with no marker is
    deliberately NOT reported as a CAPTCHA: telling someone to solve a challenge
    that was never offered is the exact confusion this issue is about.

    login_request, verify_code and verify_totp now return reason="captcha" with an
    explanation covering the three things the reporter had no way to find out: the
    credentials are not the problem, the block is keyed to the public IP address
    rather than the account, and it clears by itself within a few hours.

    Sign-in requests are then held back for 300s so Bambuddy stops deepening the
    block. Keyed per origin, not per service: TOTP verification posts to
    bambulab.com while everything else posts to api.bambulab.com, and a challenge
    seen on one must not strand somebody halfway through a two-factor sign-in on the
    other. Entries expire on read, so the map cannot grow past one per region. The
    token endpoint is deliberately left ungated -- it is the way out.

    The UI shows a persistent panel rather than a toast. A toast names a problem the
    user cannot act on and then vanishes; this one stays put and carries a one-click
    route to "Use access token instead", which is the only thing that works while
    the challenge lasts, since that path does not touch the challenged endpoint.

    MakerWorld meets the same challenge from the same edge and now shares the
    detection. It used to require the literal word "robot" in the error text and
    reported any other wording as an unexplained block.

    The System Health scanner gets a bambu-cloud-captcha signature. The reporter's
    bundle came back with zero findings while their log was full of the failure.

    Its advice for a failed FTPS handshake was corrected at the same time: it still
    blamed firewalls and outdated firmware, which the #2780 investigation ruled out
    last release -- it is the printer's own file service wedging, and the fix is to
    restart the printer. The wiki said so already; the health panel did not.
2026-08-15 14:42:27 +02:00
maziggy 430c45666a Power on a printer for jobs queued to a printer class (#2786)
Queue a print against a printer class -- "Any X1C", or a Slicer Pipeline whose
    target type is Printer class -- with every printer of that class switched off,
    and nothing happened. The job sat pending and no smart plug was touched, while
    the same file pinned to a specific printer powered that printer on within one
    queue check. The reporter's log holds both halves: thirteen minutes of the item
    being polled as (133, None, ...) and passed over, then a PATCH onto printer 2,
    then "Printer 2 offline, attempting to power on via smart plug(s)" on the very
    next tick. Same item, same plug, same Auto On setting.

    Powering a printer on had only ever been written inside `if item.printer_id:`.
    The model-based branch below it walks the same queue but its matcher classes an
    offline printer as a reason to keep waiting -- printers_offline collects the
    *name*, for the waiting reason -- and nothing on that path ever looks at plugs.

    _wake_printer_for_model adds it. The model query moves into _printers_for_model
    so the matcher and the wake step answer "which printers can this job run on"
    from one place: a job can only be woken onto a printer the matcher would also
    have considered. Candidates that failed the cross-model gate are excluded --
    switching a printer on for a file that can never legally run on it leaves the
    job just as stuck, with the printer now drawing power.

    Two things it does that the fixed-printer branch does not:

    A printer awaiting plate-clear acknowledgment is skipped. Waking it buys
    nothing; it boots into IDLE and is held by the gate. That is what the reporter's
    log shows for the eighty minutes after their manual edit -- "printer 2 not
    available -- connected=True, state=IDLE, awaiting_plate_clear=True" every thirty
    seconds to the end of the capture. The flag is Bambuddy-side and persisted, so
    it is readable while the printer is still off.

    At most one printer per pass, because each wake blocks the queue loop for the
    boot wait. Several queued jobs bring several printers up over the following
    minutes rather than a whole shelf at once.

    A failed power-on opens a 600s per-printer cool-off. Without it the walk is by
    id, the pass spends its single attempt on the same broken printer every time,
    and a healthy sibling two slots down is never reached -- one unreachable plug
    starves its whole model, and costs a 180s boot timeout out of every 30s pass.
    Entries expire on read: a printer inside its cool-off is skipped before the
    power-on is reached, so a live entry can never be overwritten by a success.

    The failed printer is deliberately NOT added to busy_printers. It is off, not
    busy; labelling it busy would misdescribe it in every later item's waiting
    reason and, because an all-busy reason is treated as needing no user action,
    suppress the notification too.

    Assignment is left to the next pass. AMS trays arrive with the first status push
    after connect, so matching filament against a printer that booted five seconds
    ago can reject the printer we just woke.

    Finally, the waiting reason separates "Offline: X1C-1" from "Offline, no Auto On
    smart plug: X1C-2". Those are different problems and only the second is one the
    user has to go and fix -- it was also the first question the reporter had to be
    asked, and the queue could not answer it.

    Tests cover the wake, the plate-clear skip in both gate states, all-candidates-
    awaiting-plate-clear waking nothing, one wake per pass, the starvation case over
    two passes, cool-off expiry, no-Auto-On-plug being left alone and named, an
    incompatible sliced model waking nothing, connected printers being left alone,
    scheduled-for-later and manual-start jobs switching nothing on, and a regression
    pin on the fixed-printer branch.
2026-08-15 14:41:44 +02:00
maziggy a9b15e727f Stop retrying a printer whose FTPS handshake fails, and name the cause (#2780)
Two printers went on printing while every archive they produced held nothing
    but a filename. Bambuddy opened port 990, the printer accepted the connection
    and answered with something that was not TLS, and connect() logged a warning
    and returned False -- indistinguishable, to every caller, from "the file is
    not at this path". So the 3MF lookup walked all six filename variants across
    five directories with four retries each, the cover endpoint ran its own
    sixteen-path sweep, and the timelapse scan added four more, all against a
    sixteen-path sweep, and the timelapse scan added four more, all against a
    printer that could not have answered any of them. One reporter's log carried
    1813 identical handshake failures, another's 3511.

    The evidence says this is the printer's own file service getting stuck, not a
    model, firmware or TLS-configuration problem. In #2780's bundle the same two
    printers ran clean from 22 July to 4 August and failed again from the 5th; a
    second bundle shows an X2D serving files for five days, flipping on 19 July,
    then failing every connection for eight days with zero successes. The same
    models and firmware appear in roughly twenty other bundles with no occurrences
    at all. Both bundles show it happening with cap_tls_v1_2 in effect -- the X2D
    and H2C entries in ftp_profiles were added on analogy with P2S to fix exactly
    this symptom, and the reporter's own debug line proves they do not.

    An ssl.SSLError from connect() now opens a five-minute cool-off for that
    printer. Subsequent connects return False without touching the network, so a
    wedged printer is contacted twice an hour instead of hundreds of times a
    minute, and the single warning that is logged names the remedy. The cool-off
    is dropped on expiry rather than kept, so the map holds one key per currently
    wedged printer. ftps_handshake_blocked() lets the sweeps stop: the 3MF lookup
    abandons the remaining paths and skips the directory-walk fallback, the cover
    endpoint returns 503 naming the file service instead of a 404 that reads as
    "this print has no thumbnail", and the timelapse scan separates 503 (cannot
    reach the printer) from 404 (no timelapse directory) -- one 500 used to cover
    both, which is what the reporter hit when reproducing.

    The Connection Diagnostic completed a bare TCP connect to 990, which is why it
    reported the port green throughout: the port is open, it is what is behind it
    that is broken. It now completes a real implicit-TLS handshake using the
    model's own ftp_profiles cap, so a pass means the FTP client would also get
    through. An open port that cannot negotiate reports warn with reason no_tls,
    selecting a new message in all 13 locales that points at a printer restart
    rather than at the firewall. No login is attempted, so this stays valid in the
    pre-save Add Printer flow.

    The cool-off tests run against a real socket that accepts on 990 and replies
    with a plaintext FTP banner, reproducing WRONG_VERSION_NUMBER rather than
    mocking ssl. The autouse fixture clearing _mode_cache now clears the cool-off
    map too -- every test here talks to 127.0.0.1, so one left behind would make
    the next test's connect() a no-op.
2026-08-15 14:41:08 +02:00
maziggy b0927b426c implemented pr (minor) feedback 2026-08-15 14:40:37 +02:00
maziggy 40ba1f4b0e implemented pr (worth fixing) feedback
update commit
2026-08-15 14:40:25 +02:00
maziggy 7dcfd0921d Check filament deficit for Library-backed queue items (#2779)
A job needing 20.5 g was dispatched onto a spool holding 9 g and the printer
    started. _resolve_source_3mf returned LibraryFile.file_path verbatim, but that
    column stores a path relative to base_dir -- so it resolved against the process
    working directory, found nothing, and compute_deficit_for_queue_item treated a
    missing source as "nothing to verify" and returned no deficit.

    Every library-backed queue item was affected: Slicer Pipeline jobs, which are
    always library-backed, and everything added through the Library's bulk Add to
    queue. Both callers share the resolver, so the Play button on the queue was as
    blind as the auto-dispatcher. Archive-backed items (print history, VP intake)
    resolved correctly and were never affected, and neither was PrintModal, which
    resolves the file on its own path.

    The library branch now uses the same idiom as the eleven other readers of
    file_path -- absolute stays, relative joins base_dir. The join carries a
    SEC-PATH-OK marker: the value is DB-stored and generated by the Library ingest,
    and it is already what resolves the file for upload, so the check has to
    resolve it identically or it is not checking what gets printed.

    A source that is configured but absent now logs a warning naming the item and
    the resolved path. It still dispatches, because the upload needs the same file
    seconds later and fails there, where blocking would strand a queue on a moved
    file -- but a safety check that skips itself must not do so in silence, which
    is what hid this for every library-backed item.

    Tests cover the relative path (the reporter's 20.5 g against 9 g), the absolute
    path against a base_dir the file is not under, and the missing-source warning.
    The existing cases all used archives with absolute paths, which is the gap the
    bug lived in.
2026-08-15 14:40:02 +02:00
maziggy 34243ce7e4 Accept Forgejo tokens scoped to a single repository (#2775)
ForgejoBackend.test_connection asked GET /user who the token belonged to
    before asking whether the token could reach the repository, and treated a 403
    there as fatal. A Forgejo v15 repository-scoped token may only carry
    read/write on issues and repositories, so it 403s on /user -- and was rejected
    despite reaching its own repository fine, which is all a backup needs: the push
    path uses the Contents API and restore reads commits, trees and blobs, all
    under /repos/{owner}/{repo}. That /user call was the only one in the whole
    provider layer.

    The probe stays, because a 401 from it is genuinely conclusive and names a bad
    token before the repo call has to guess -- Forgejo v15+ hides a private repo
    behind 404 rather than 403, so the repo call cannot always tell those apart.
    Every other status now falls through to the repo check.

    Two additions keep the messages as sharp as before: the repo call's own 401 is
    mapped to "Invalid access token" instead of a generic API error, and the 404
    names write:repository and the scoped-to-another-repository case, mentioning a
    possibly-invalid token only when /user did not confirm the identity.

    The token hint under the field was one shared string reading "fine-grained
    token with Contents read/write" -- GitHub's advice, shown to Gitea, Forgejo and
    GitLab users too. It is now per provider via PROVIDER_TOKEN_HINT_I18N_KEY,
    following the existing repo-URL placeholder map, translated in all 13 locales.

    Tests pin the repository-scoped token connecting, a transient /user status not
    blocking the repo call, both 404 wordings, and the repo-call 401; a frontend
    test switches providers and asserts the hint follows.
2026-08-15 14:39:32 +02:00
maziggy ffcfa70e5b Record who queued a file from the Library and the webhook API
PrintQueueItem.created_by_id is what the queue:read_own / queue:update_own /
    queue:delete_own permissions filter on, but only three of the paths that create
    queue items were setting it.

    The Library's bulk "Add to queue" required Permission.QUEUE_CREATE and then
    bound the dependency to `_`, discarding the user, so every item it created was
    ownerless -- and invisible to the person who added it if their permissions are
    scoped to their own work. That is the one path built for adding many files at
    once, which is where it was hardest to notice.

    The webhook queue endpoint has no request user, but APIKey.user_id records the
    key's owner, which is the acting identity everywhere else the key is used, so
    its items are credited to that owner. Keys minted before per-user ownership
    have no user_id and their items stay ownerless.

    The virtual-printer path is left as-is on purpose. VirtualPrinter carries no
    owner, and the obvious substitute is wrong rather than incomplete: one admin
    typically configures the VP while everyone sends prints through it, so
    crediting those to the admin would make the "added by" column lie and put other
    people's jobs in the admin's own queue. Existing NULL rows are not backfilled
    -- there is no record of who created them, and the ownerless case is already
    handled throughout.

    Tests pin both fixed paths and the two cases that must stay ownerless (auth
    disabled, legacy key).
2026-08-15 14:39:11 +02:00
maziggy 035faee06e Merge pull request #2714 from jmoore-skild/feature/2656-restore-from-github
feat(backup): restore selected categories from a Git backup commit
2026-08-15 14:38:35 +02:00
maziggy c38e6566f3 fix(backup): report the categories a failed restore already committed (#2656)
The service reports what landed on a part-way failure -- categories commit as
    they finish, so results names the ones on disk -- and the modal gated the
    whole result panel on success, so it showed the failure message and dropped
    them.

    The cache invalidation was inside that same branch, which is the half that
    mattered: a run that committed the settings category and then failed left the
    app rendering pre-restore settings, with no reload and no re-read, which is
    the failure the modal's own reload-on-close exists to prevent.

    Gate on what was written instead. A refusal that never reached a category
    still carries an empty results and still keeps the form, so the mutex and
    backup-in-flight cases are unchanged. A partial does not read as a success:
    the tick becomes a warning and a line says the listed categories are the ones
    on disk.

    ---

    fix(backup): keep the local owner when the backup names one we cannot resolve (#2656)

    An owner the backup names but this instance has no user for was written as
    NULL, and overwrite is a blanket setattr -- so restoring over a local archive
    that had a perfectly good owner took it away, which is the 404-for-its-own-
    owner failure this column is carried across to fix. Resolving by username
    widened the trigger from a stale id to any user renamed since the backup.

    It is the same state as an absent key: the backup has not told us who owns
    this. So it takes the same action -- the column is not written at all.
    Overwrite keeps the local owner, insert lands ownerless with the note, and an
    explicit null still writes, so overwrite still means "match the backup".

    The notes move to the insert path with it. On overwrite nothing was taken
    away, so there is nothing to warn about, which is the rule the absent-key
    case already follows.
2026-08-15 14:38:18 +02:00
maziggy cde7bc9e12 . 2026-08-15 14:37:27 +02:00
maziggy 0dfb53c5c5 Resolve a spool's material to a known drying preset before starting a cycle (#2774)
The drying popover prefilled its material from the loaded spool without
    checking the preset table had that material. An AMS-HT holding Support for
    PLA/PETG (tray_type PLA-S) fell back to PLA's temperature but kept PLA-S as
    the material, and the dropdown displays its first option when handed a value
    outside its list -- so it read PLA while PLA-S was sent. Same gap for every
    composite: PETG-CF prefilled at PLA's 45C.

    Resolve the tray_type to a key the table has before setting either value.
    Support materials and composites resolve to their base, nylon is aliased
    under its several spellings, and anything unrecognised falls back to PLA --
    the coolest row, so an unknown material under-dries rather than deforming a
    PLA spool.

    Also record request-topic messages in the MQTT debug log. That topic carries
    every command a printer is given, including Bambu Studio's, and returned
    before the logging block -- so a capture could show only what the printer
    said, never what it was told.
2026-08-15 14:36:57 +02:00