Commit Graph
3983 Commits
Author SHA1 Message Date
maziggy 95e28e39ce Bumped version 2026-08-15 15:07:17 +02:00
maziggy 1f3d66e29d [Feature]: Server-Side Slicing on linux/arm64 systems (#1900)
* Add override file for ARM64 setups.

    This commit adds an override file which explicitly specifies the container platform to be linux/amd64.
    It forces docker to pull/build/run the amd64 image (even on arm64 hosts).
    Assuming binfmt support is set up, this will run the amd64 applications via emulation.

    * Add arm64 override file information to README.

    Adds a section covering the experimental setup for
    arm64 hosts to the README.

    * Make the ARM64 override survive the next compose command (#1900)

    The override only applies while both -f flags are on the command line, and
    every other instruction in this README is written bare. An ARM64 user who
    followed the update steps would drop the platform pin without noticing: a
    manifest error today, and a silent switch off emulation once native ARM64
    images ship. The quick start now writes COMPOSE_FILE into .env, so the rest
    of the file works unchanged on ARM64 -- verified both ways, with and without
    that line.

    Two things the setup needs stated where it is read rather than one hop away
    in the wiki: binfmt has to be registered on the host or the container dies
    with "exec format error", and emulation costs roughly 3-6x native slice
    time. Both now lead the section, and the separate-x86_64-box route stays the
    recommendation it was -- emulation is the fallback for people who have no
    second machine, not a replacement.

    The compose file's own header said ARM64 was a dead end. It now points at
    the override, for anyone who reads the stack instead of the README.
2026-08-15 15:03:46 +02:00
maziggy e899022116 fix(profiles): read the companion files that hold a preset's real gcode
A bundled preset can keep a setting in `<preset> template <key>.json`, a
    file the preset itself does not reference -- the desktop slicer finds it
    by name. Walking only `inherits` never reached it, so every one of the 56
    instantiable BBL machine presets resolved `machine_start_gcode` to the
    577-character generic block on fdm_machine_common instead of its own
    6.5-21 KB one. That block holds the M620 AMS load and the M1002
    gcode_claim_action calls, so a print sliced from it heats the bed, moves
    the toolhead and extrudes nothing (bambuddy#2838).

    Companions are now folded into each ancestor as the chain is walked, at
    that ancestor's precedence, so a caller's own value still wins and the
    0.2/0.6/0.8 variants reach their 0.4 sibling's companion. They are found
    by listing rather than by a fixed set of keys.

    Covered against the shipped bundle, not fixtures: a new e2e spec resolves
    all 56 presets inside the image and fails on any that still lands on the
    generic block.
2026-08-15 15:03:28 +02:00
maziggy 2025731f91 Updated BACKERS.md 2026-08-15 15:03:09 +02:00
maziggy 2d324dc7d0 Let API keys read and run slicer pipelines (#1425 follow-up)
Every pipeline endpoint answered 403 for API keys whatever scopes the key
    carried. PR A parked all three permissions on the admin denylist until the
    run dispatch existed to decide about; it landed in PR C and the parking was
    never revisited.

    PIPELINES_READ now rides can_read_status. PIPELINES_RUN requires
    can_queue AND can_manage_library together, so the allowlist gained tuple
    values: a run slices into the library and then queues prints, and mapping
    it to either flag alone would hand that flag the other one's authority.
    The 403 names every flag the key is short of. PIPELINES_WRITE stays
    admin-only -- a key can run the recipe, not rewrite it or clear the log.

    Opening the run route also needed the cloud-owner fallback the direct
    slice route makes: a pipeline can carry Bambu/Orca Cloud presets, and
    resolving those reads a token off a user record that an API-keyed request
    does not have. retry_failed forwards the new dependency explicitly,
    since a direct call receives the Depends marker rather than None.
2026-08-15 15:02:53 +02:00
maziggy ad2b22dd6b Remember the Printers page's status and location filters (#2833)
Pick a location, navigate away, come back, and every printer was showing
    again. Both filters were plain useState, and the only preferences on that
    page that were not remembered -- sort order, card size, view mode,
    collapsed sections and hide-disconnected all persist, each with the same
    initializer-plus-setItem shape. Give these two the same treatment.

    A saved filter needs a way out, though. The location dropdown is only
    rendered while at least one printer has a location, so a saved location
    that was later renamed or removed would match nothing and take its own
    dropdown off screen with it -- an empty page and no control to undo it.
    A location that is not among the available ones now resets to all, and
    the same for a status the dropdown does not offer.

    That check waits for the printers query to resolve. The list is undefined
    while it is in flight, so the available locations start out empty, and
    acting on that would throw the saved filter away on every page load.

    Search stays unpersisted: a box that silently refills itself on return is
    a surprise rather than a convenience.
2026-08-15 15:02:36 +02:00
maziggy aa4df0c57a Build the slice output's path from a name a folder can have (#2832)
A print's display name comes from inside the 3MF, not from the filename,
    so a MakerWorld title arrives with its punctuation: "Planter Pot with
    Drip Tray, 12 cm / 5 inches". The slice-to-archive sink used it verbatim
    for the output folder and the output file, and a slash in a folder name
    is not a character -- it is another folder. mkdir(parents=True) created
    the level it implied and the file's own join added a third that nobody
    had made, so the slice failed with ENOENT on a path that half existed.
    Renaming the print first was the only way through.

    Reduce a display name to a single path component before it becomes one.
    Characters a name cannot hold are replaced rather than dropped, so the
    folder still reads like the model's title, and the set is the one the SD
    card already rejects -- which covers a Windows install too, where the
    colon in "Model: v2" fails the same way. The name shown in Bambuddy is
    untouched: a title is allowed its punctuation, and refusing the slash
    would reject the name this was reported about.

    The joins are asserted to stay under the archive directory. That was
    already claimed by a SEC-PATH-OK marker on both lines, citing a
    sanitiser that is defined in another module and was never called here;
    without the marker the path-join backstop flags them both. The claim is
    now true, and a future edit that reaches around the reduction is caught
    rather than trusted.

    The library sink takes the same embedded name, so it gets the same
    reduction: managed storage names the file after a UUID and never saw
    this, but an external folder writes the name as given.

    Display names are also stripped of control characters on the way into
    the database, in the schema and in the archive service. The validator
    hands back anything that is not a string rather than iterating it, so
    the field still answers a list or a bare int with a 422 instead of
    accepting the one and failing on the other.

    Display names are also stripped of control characters on the way into
    the database, in the schema and in the archive service, cleaned before
    the filename fallback rather than after it so a whitespace-only embedded
    name still falls through to the filename. The validator hands back
    anything that is not a string rather than iterating it, so the field
    still answers a list or a bare int with a 422 instead of accepting the
    one and failing on the other.
2026-08-15 15:02:17 +02:00
maziggy dde724f83c Repair no-3MF archives' photos and their silent filament writes (#1820)
Two faults behind the same kind of print: one that arrives without a
    retrievable 3MF, which on an H2S is any job started from the printer's
    own internal library.

    Such an archive has no file_path, and Path("").parent is Path("."), so
    every site that derived the archive's folder from it landed on the data
    directory itself. The finish-photo capture spotted that and wrote to
    <archive_dir>/<id>/photos instead. Nothing else did. The photo was
    written in one place and looked for in another: reads 404'd, deletes
    dropped the name and left the file, and the notification attachment
    never found the image. Hand-uploaded photos worked only because upload
    and read agreed with each other rather than with the capture. Give the
    question one owner in utils/archive_paths and have all four sites ask
    it. Lookups check the old shared location too, so photos already
    uploaded there stay reachable; uploads now go where captures go.

    Separately, the remain%-delta fallback that stands in for a missing 3MF
    can charge nothing for several reasons, and did so without a word. The
    AMS reading is coarse and, on the reporter's printer, noisy: it rises
    mid-print, swings five points over a job, sits at 100% through a
    36-minute print on a fresh spool, and goes negative on a nearly empty
    one -- which the start-of-print gate rejects, dropping the only slot
    that was printing. Two of their prints went uncounted for two different
    reasons and both read as "no spools updated", which is also what a print
    with nothing to charge prints. Name the slot and the two readings in
    each case, on the Spoolman path and on the internal-inventory path,
    which has carried the same gates since #1119.

    The Spoolman path also had no notion of which slots the print used, so a
    spool swapped into an idle slot mid-print reads as consumption and is
    billed to whoever that slot is assigned to -- the fault #1269 fixed for
    the internal tracker, still open here, and likeliest on exactly the
    prints this fallback serves, where nothing else narrows the field. Use
    the same three pieces of evidence it does: the print's mapping, its
    mid-print tray changes, and the tray it started on. The last needs
    storing, because the internal tracker's row is deleted before this runs
    and a screen-started print has no mapping to fall back on -- hence a new
    nullable column, and no backfill, since a row from before it existed has
    nothing to say. Where no evidence exists at all, every slot is still
    considered.

    Both paths also treated tray_now == 255 as naming a slot. It does not:
    it is the field's initial value, the fallback for an unparseable
    reading, and what it reports with nothing loaded. Mapped as a tray id it
    becomes (255, 1), so as the only evidence it excluded every real slot
    and charged nothing at all -- this issue's own bug, arriving by a new
    route. On the internal path that is live today; on the Spoolman path it
    would have shipped with the guard above. The external holder reports 254
    when it is genuinely in use.

    The arithmetic is untouched: at one percent per step this cannot resolve
    a small print, and pretending otherwise would be worse than saying so.
2026-08-15 15:01:42 +02:00
maziggy 15eace71d6 Show the plug that powers the printer in the card's Power row (#2830)
A printer card has one Power row: a plug name, its draw, and the auto-off
    and on/off buttons. Which plug filled it was decided by nothing -- the
    endpoint returned the first row the database handed back that was not a
    Home Assistant script, from a query with no ORDER BY.

    For the reporter that was an enclosure exhaust fan, added before the
    outlet their X1C is plugged into. The card showed the fan's name with
    '--' for watts, offered to switch the printer off by cutting the fan,
    and demoted the metered outlet to the small HA button row. The fan was
    marked as not powering the printer and hidden from the card; neither
    setting was consulted here, though controls_printer_power has decided
    the scheduler's power-on pick since #2629.

    Rank the candidates instead: switchable at all, controls_printer_power,
    enabled, show_on_printer_card, reports power, lowest id. The first rules
    out a script, which can only be run, and an MQTT plug, which the control
    endpoint rejects as monitor-only -- and an MQTT plug is exactly the kind
    that reports watts, so without it ahead of the power tiebreak the row
    could land on a plug whose on/off button answers with an error. The last
    is not cosmetic: with no ORDER BY, a plain UPDATE on PostgreSQL can move
    a row and silently swap which plug the card calls the printer's power.

    None of these excludes a plug. A printer whose only plug is hidden,
    disabled or monitor-only still needs its Power row, because that row
    holds the on/off button and the HA buttons are drawn inside it.
    controls_printer_power sits above show_on_printer_card because the two
    only disagree when the plug that really feeds the printer is hidden, and
    letting a display preference win there points the power buttons at an
    accessory -- the fault #2629 fixed. Power capability is read from the
    configuration, not measured: this runs on every card render, and it is
    approximate both ways, so it only breaks a tie.

    The scripts endpoint shares the same pick and excludes it, so a
    switchable main plug is not repeated as a button directly below itself.
    A script is left in place: a printer whose only entities are scripts
    falls back to showing one in the power row, and taking it out of the
    button row too would cost it the one-click run it has always had.
2026-08-15 15:01:19 +02:00
maziggy 46aa6affd3 Match a print completion to its queue job the way the printer names it (#2829)
Bambuddy has no run identifier to tie a completion to a queue row, so it
    finds the row by printer and status='printing' alone. b5a34b7ba added a
    check that the completion's subtask name agrees with the file the row was
    dispatched with, so the printer's own calibration runs cannot close
    someone's job early. It compared the two names verbatim.

    The printer does not echo them verbatim. It substitutes underscores for
    spaces, so 'H2D_Carbon_Filter_(V2)_Body & Solid Lid' came back as
    'H2D_Carbon_Filter_(V2)_Body_&_Solid_Lid', the check refused it, and the
    row stayed printing. check_queue counts every printing row as a busy
    printer and nothing else ever closes one, so the printer's queue stopped
    until someone cancelled by hand. It also truncates long names and marks
    the cut with '...', which would have done the same to any long title.

    Compare on the canonical form instead -- case, spaces and underscores --
    which is the rule the 3MF lookup in this module has always used for the
    same names, and treat a truncation marker as a prefix match. The check
    keeps its purpose: the same printer the same day correctly refused a
    completion for auto_pa_line_calib_mode.

    One comparison being stricter than reality should not be able to stop a
    queue indefinitely, so the scheduler now closes a row itself when it has
    been printing for five minutes after its printer went terminal, with the
    status that state implies. A real completion arrives within seconds, so
    this only sees rows that were already stranded, and a disconnected
    printer never qualifies. It restores the queue only -- notifications,
    billing and auto-off are not replayed minutes late.
2026-08-15 15:01:00 +02:00
maziggy 2548a5d49d Show the printer card thumbnail again after navigating back to the page (#2826)
The cover URL is cache-busted on the print name, which does not change
    while a print runs. Leaving the printers page and returning therefore
    re-mounts with a byte-identical src, which the browser serves from its
    in-memory cache with no network request -- which is why the reporter's
    network panel was empty while the placeholder sat there.

    `loaded` could only ever be set by onLoad, and a mount effect reset it to
    false unconditionally. For a cache hit those are two racing tasks with no
    ordering between them: when the load event won, the effect undid it, and
    nothing put it right afterwards because the URL does not change again for
    the rest of the print. Read the element's own complete/naturalWidth in
    that effect instead of assuming nothing has loaded. That settles it
    whichever task wins, and also covers the variant the reporter proposed,
    where the handler is not live in time.

    Being a race explains why it reproduced 100% for the reporter and not at
    all here. Only the printer card was affected; archive thumbnails build a
    fresh URL every mount, so they always hit the network.

    CoverImage is exported so the regression tests can mount it directly.
2026-08-15 15:00:42 +02:00
maziggy b83eea07cd Explain a print that never reached the printer's card, instead of sweeping for it (#2780)
Bambuddy reads a print's 3MF, cover and timelapse over FTPS on port 990,
    which on every Bambu model serves external storage only. Under some
    configurations H2-series and P2S firmware keeps the sliced file on
    internal storage, where Bambu Studio put it over the port-6000 service,
    and then no path on 990 can find it.

    The print command has always said which of the two it used -- `url` reads
    ftp://<name> or brtc://emmc/<name>. We discarded it and swept anyway:
    ~110 connections per print, all certain to fail, ending in an archive
    card with nothing on it and no stated reason. In the reporter's bundle
    all 35 dispatches to their H2C and P2S said internal storage, all 25 to
    their X1C said external, and all 44 empty cards belonged to the first two.

    Read the field, skip the sweep when it cannot succeed, and record which
    reason applied. A printer that uses the card is unaffected, and so is one
    we have no answer for -- silence is not evidence, and reading it as bad
    news would break archives that work today.

    The answer is held per print and dropped when that print ends, rather
    than kept as a standing fact about the printer. Plenty of prints never
    announce themselves: 14 of the 79 print starts in that bundle arrived
    with nothing on the request topic, started from the printer's own screen
    or picked up after a restart. Left standing, one slicer print to internal
    storage would suppress the lookup for every screen-started print after
    it, on a printer whose files really are on the card. The sticky reading
    is kept for the connection diagnostic alone, which is run after the print
    that prompted it and would otherwise have nothing to report.

    Two things that pointed the wrong way go with it. The archives banner
    told everyone to enable "Store sent files on external storage"; the
    reporter had it on for the whole three weeks and it would not have
    helped. The diagnostic passed a printer whose slot was empty, because it
    read only the toggle -- an empty slot is now a failure naming the slot,
    and a printer that has storage and still used its own is a warning. On
    P1-series that empty-slot failure yields to the existing unsupported-model
    skip: the toggle cannot be switched on there at all, so telling the
    operator to insert a card would promise a fix inserting a card does not
    deliver (#2524).

    Also close FTP sockets on the failure paths, which dropped them for the
    garbage collector -- 1813 in a day in that bundle -- and drop the advice
    to restart the printer, which the reporter tried twice while a single
    manual connection to the same printer handshook cleanly.

    This does not make the affected prints archive in full; that needs the
    port-6000 protocol tracked in #2762.
2026-08-15 15:00:16 +02:00
maziggy 8e553289db Choose which rack nozzle each filament prints from on an H2C (#1784)
The Vortek rack holds six hotends, and a multi-colour plate is sliced to
    use a different one per colour so it can skip the purge. Which of the six
    each colour takes is not in the 3MF. The same plate, sliced and sent twice
    from Bambu Studio with a different choice each time, produces two files
    that differ only in rounding in the last digit of a few extrusion figures
    -- the filament grouping, the toolchange stream, the 120 nozzle-change
    markers and project_settings.config are all identical. The choice travels
    only in the dispatched nozzle_mapping.

    Bambuddy had no way to state it, so those plates went out with no nozzle
    assignment at all and the printer chose for itself. That is what levelled
    on one hotend and printed with another, millimetres above the plate.

    Every rack-bound filament now carries a position picker beside its AMS
    slot dropdown, listing all six with the nozzle each holds. An empty
    position, or one holding the wrong diameter or flow type, is shown greyed
    out with the reason rather than hidden, so someone looking for position 4
    finds it. The choice is per filament *group* rather than per slot, because
    a group is one hotend: two filaments the slicer grouped together share it
    and cannot point at different positions.

    Nothing has to be picked. Positions are assigned automatically, preferring
    one already loaded with that colour, which on the plate this was built
    against reproduces Bambu Studio's own pick exactly.

    A nozzle currently picked up onto the carriage is offered too. The
    firmware drops its rack position from the report entirely rather than
    sending a placeholder (#943), and refusing it would rule out the position
    most likely to be wanted -- the one the last print left mounted. Only
    recoverable when exactly one position is missing; two gaps are genuinely
    ambiguous and stay unavailable.

    Positions are re-checked at dispatch, not just when queued, because the
    rack can be re-loaded in between. The two failure modes differ on purpose:
    an explicitly chosen position that no longer fits stops the print, names
    what the position now holds, and deletes the uploaded file from the SD
    card so it cannot be started by hand either -- an operator who named a
    hotend must not silently get a different one. An automatic assignment that
    cannot be made instead falls back to letting the firmware choose, which is
    what happened before any of this existed.

    The pick is stored as {group: position} rather than as the expanded
    nozzle_mapping, though that is what goes on the wire. That column means
    "Bambu Studio decided, forward verbatim", and only the group-and-position
    form can be re-checked against what is actually mounted at dispatch.

    The existing multi-rack refusal in extract_nozzle_mapping_from_3mf stays.
    It still guards the #2800 fallback, which can only ever name one rack id.

    Measured on the maintainer's H2C: rack position n is physical nozzle id
    15 + n, confirmed by cross-referencing two captured dispatches against
    Bambu Studio's own dialog. extruder_max_nozzle_count names which carriage
    is the rack straight from the file, and is read rather than assumed -- a
    fourth independent confirmation of the carriage indices fixed in 45dc139.

    The print dialog is also wider, on every printer. Its filament rows carry
    the most horizontal content in it and adding a picker truncated names to
    "Bamb...". The column widths themselves only change on a rack machine.

    Tests: 44 unit covering the plan, the resolver, the mounted-nozzle
    recovery and every refusal; 9 dispatch integration asserting the two real
    captures end to end; 7 API round-trip; 33 frontend. The API ones exist
    because two integration bugs got through a green suite that tested the
    pieces and not the seams -- the group data reached only one of the three
    filament-requirements routes, and the field was declared on every schema
    except the create one, where Pydantic dropped it in silence.
2026-08-15 14:59:41 +02:00
maziggy 7787b3fc0f Grow the H2C nozzle rack with the printer card size
The six rack chips were a hard-coded 28 pixels at every card size, while
    the body type and icons around them scale by 20% at L and 40% at XL. Set
    the card larger and the rack stayed put -- a shrunken strip beside
    neighbours that had grown around it, with the diameter figures pressing
    against the edges of chips that had not moved.

    The chips now read their size from the same scale as everything else,
    which needed one new rung: the icon tokens run in quarter-rem steps (i2
    is 8px, i3 12, i4 16, i5 20), so 28px is i7. S and M are unchanged, as
    they are for every other property that control scales.

    The card is sized to its contents, so it simply takes the extra width
    rather than being told a new one -- and at L and XL that row has the room
    to give, so the temperature readings beside it do not go back to wrapping.

    Two tests: one pins i7 to 33.6px at L and adds it to the sweep asserting
    every token is set at XL, the other asserts the chip reads the token, so
    a revert to a fixed class fails rather than silently regressing.
2026-08-15 14:59:19 +02:00
maziggy 58594c60c9 Print an H2C two-nozzle plate from the carriage it was levelled on
The two carriages were the wrong way round: extruder index 0 was treated as
    the fixed hotend and index 1 as the swappable rack, and it is the other way
    about. A plate using both was levelled with one nozzle and printed with the
    other, several millimetres off the plate.

    Three sources agree, and disagreed with the code. Telemetry reports
    ams_extruder_map {'0': 1, '1': 0, '2': 0}. BambuStudio, dispatching a plate
    that used all three of those AMS units, sent the filament from the unit on
    extruder 1 to physical nozzle 1 and the ones on extruder 0 to rack positions
    16 and 18, and that print completed. And the two constants could not both
    have been right: _FIXED_NOZZLE_ID is 1 while the fixed extruder was 0, in a
    scheme where physical nozzle id N sits on extruder N.

    The old value came from the #2800 hardware A/B, where [17, -1, -1, 1] printed
    in mid-air and [1, -1, -1, 17] printed correctly. That result stands -- it
    established which wire worked. The extruder indices were not measured by it;
    they were inferred by pairing the working wire with a slot_extruders list
    produced by the 3MF reader that has since turned out to mis-read exactly
    these files. The reasoning is recorded at the constants so a future
    regression report is not re-litigated from scratch.

    Also withholds nozzle_mapping entirely when more than one filament group
    needs the rack. Two groups on one extruder means that extruder is a rack and
    the plate wants a different hotend per group -- which physical slot each
    takes is the slicer's choice against the live rack and is stated nowhere in
    the file, since both groups can carry identical diameter and nozzle type.
    Studio dispatched such a plate to 16 and 18; nothing here can reproduce that,
    and answering anyway is what printed in mid-air, so the firmware picks.

    Restores the fixed 32-entry padding that dfeac792f replaced with the plate's
    slot count. That was derived from a single 3-entry capture which turned out
    to be a calibration job; Studio's dispatch of a real project print on the
    same machine is 32 entries.

    Both constants are read in one function, on the nozzle-rack path, so no other
    model is affected. Verified on hardware: the plate that printed in mid-air
    now prints.
2026-08-15 14:59:02 +02:00
maziggy 3ce4ecfcf7 Report a print stage we cannot name at the default log level
STAGE_NAMES is hand-maintained and every new model adds to it, so a printer
    occasionally reports a number that is not in it and the card reads "Unknown
    stage (72)" -- which an H2C did, where the table runs to 66 and then jumps
    to 74. Stage transitions were logged only at DEBUG, off in normal running,
    so the sole record that it had happened was the card itself, and by the
    time anyone looked the printer had moved on.

    The asymmetry is the point: a stage we can name is worth DEBUG, and the one
    we cannot is the interesting one. An unnamed stage is now logged at INFO,
    once per stage number per session, with the model, the stage it came from
    and the print state at the time -- which is what naming it afterwards
    needs. Named stages are unchanged, so a normal print logs nothing new. -1
    is excluded: it is Bambuddy's own "not in a stage" sentinel and the field's
    initial value, so every print would otherwise report it on the way out of
    its last real stage.

    Fixes a latent crash found while testing this. The stage-change log line
    builds its text before the log level is consulted, so get_stage_name runs
    on every transition whatever the level is set to; a stg_cur that was not
    hashable -- malformed telemetry rather than an unknown stage -- raised
    TypeError out of STAGE_NAMES.get and aborted the whole state update.
    Labelling a value can no longer do that.
2026-08-15 14:58:45 +02:00
maziggy c5cd0dbe77 Stop an H2C refusing a multi-colour print as a hotend mismatch
The print uploaded, the printer took the command, and stopped at once with
    HMS 0500-4047 -- "the available hotend quantity or model does not match the
    sliced file". nozzle_mapping told the printer one of the plate's filaments
    went to no hotend while ams_mapping named the tray it comes from, and the
    firmware will not start a job on that contradiction.

    Each filament in a 3MF names the group it belongs to, and on every other
    dual-nozzle Bambu the group number is also the extruder index, so it was
    read as one. On a rack machine it is not: the rack carriage holds six
    hotends to the fixed carriage's one, so the slicer writes a group per
    nozzle rather than per carriage. The failing plate carried groups 0, 1 and
    2 against a two-entry physical_extruder_map, and the filament in group 2
    was dropped -- indistinguishable downstream from a slot the plate does not
    print, which is what reached the wire as -1.

    extract_nozzle_mapping_from_3mf now resolves the group through the table
    the file states for itself, the <nozzle id extruder_id> elements in
    slice_info.config. Files carrying no such table keep the direct index, so
    H2D slices are unaffected. A filament that still cannot be placed drops
    the whole mapping with a logged reason instead of half an answer: the
    firmware then picks its own nozzle, which is the pre-existing behaviour
    and far better than an answer that contradicts itself.

    Two related faults fixed in the same pass. The mapping was read across
    every plate in the file, so on a multi-plate project a slot took its
    extruder from whichever plate came last; it is now scoped to the plate
    being dispatched, in extract_filament_requirements as well. And the array
    is now one entry per filament slot, matching BambuStudio's own dispatch of
    [1, 16, 16] for a three-filament plate, rather than padded to a fixed 32.

    Verified against the file that failed: slot extruders [-1, 1, 0] became
    [0, 1, 0], and the wire [-1, 16, 1, -1 x29] became [1, 16, 1].
2026-08-15 14:58:27 +02:00
maziggy 1b944739d2 Size the H2C nozzle rack card to its contents and number its slots
The rack card shared a row with the nozzle, bed and chamber readings but
    was set to flex: 2 1 190px -- a 190px floor plus twice their growth share
    -- to draw six fixed 28px chips. On anything wider than a compact card it
    claimed several hundred pixels and left most of them empty, and the width
    came out of the cards that needed it: the combined dual-nozzle reading was
    wrapping "220 / 220" onto two lines beside a mostly blank rack. It is now
    flex: 0 1 auto, so it takes its content width and gives the remainder
    back. Shrink stays enabled so it still gives way on a narrow card instead
    of overflowing.

    Each slot also carries its physical rack position 1-6 below the chip, so a
    nozzle can be named rather than counted along. The numbering is positional
    -- an empty slot keeps its number -- so "the nozzle in slot 4" means the
    same thing however many of the six are occupied.

    The #943 regression test read every span in the slot row, which the new
    number spans would have interleaved with the diameters; it now matches the
    diameter spans specifically. A second test pins the 1-6 labelling across a
    rack with empty positions.
2026-08-15 14:58:09 +02:00
maziggy 12a34448f9 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-15 14:57:41 +02:00
maziggy 292b1b416b Pick the nearest eligible filament colour instead of the first one in tray order (#2804) (#2823) 2026-08-15 14:57:14 +02:00
maziggy 3031e52ddf 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-15 14:56:58 +02:00
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