Commit Graph
4265 Commits
Author SHA1 Message Date
maziggy 2f7ec240fd fix(ams): stop reading the printer's command acks as status (issue #3040)
Every project_file carried "cfg": "0" — the device-config bitmask, which
    Bambu Studio has never sent and the firmware ignores. The printer echoes a
    command's fields back in its ack, and the ack was ingested as telemetry, so
    bit 18 read as "AMS Filament Backup off" 25 ms after every dispatch.

    Families that repeat cfg in their periodic status (P2S, H2C, X2D) corrected
    themselves a second later; the P1S, A1, A1 Mini and A2L send it only in a
    full status dump, so the wrong value stuck and silently disabled the
    prefer-lowest-remaining gate. The A1 family, which reports no cfg at all and
    is meant to stay "unknown", was pinned to a definite "off".

    Acks are no longer read as status, for the backup bit or the per-job
    timelapse flag they also echo, and cfg is gone from the print command.
2026-09-20 13:32:50 +02:00
maziggy 3953f88ce8 Updated BACKERS and SPONSORS 2026-09-20 13:32:24 +02:00
maziggy ec31bf6223 chore(deps): bump vitest to 4.1.11 for the mocker path-traversal advisory
@vitest/mocker registers a redirect mock's target without checking it
    against Vite's file-serving allowlist, and the load hook then returns
    readFile(mock.redirect) as the module source. The target is built as
    join(root, new URL(redirect).pathname), which confines nothing -- a
    non-special scheme keeps ".." in pathname, so the join resolves outside
    the project root. GHSA-82fw-gwwq-j7x9, CVSS 5.9, CWE-22.

    Not reachable here. The unauthenticated path is the public mockerPlugin
    and interceptorPlugin exports, which attach to Vite's unauthenticated HMR
    socket for the benefit of third-party dev servers; nothing under
    frontend/src imports either. Browser mode, which registers over a
    token-authenticated RPC, is not installed -- @vitest/browser is an unmet
    optional peer -- and vitest.config.ts runs plain jsdom, so no dev server
    listens during a test run. Both packages are devDependencies and reach no
    shipped artifact.

    vitest and the eight @vitest/* packages go 4.1.8 -> 4.1.11, carrying
    es-module-lexer, expect-type, obug, std-env, tinyexec and tinyrainbow.
    Fifteen lockfile entries, all dev-scoped, none added or removed. The
    ^4.1.8 range already admitted the fix, but the declared floor is raised
    so a regenerated lockfile cannot resolve back beneath it. No src change,
    so the bundle is byte-identical and static/ does not move.
2026-09-20 13:32:04 +02:00
maziggy 5ec99a7e06 fix(ams): resolve a slot's K profile by index when the printer does not file per hotend (issue #3044)
An X2D with two AMS 2 Pro, one per hotend, showed a K value on every slot
    of the first and nothing on any slot of the second. Configure Slot was
    worse than blank there: the picker offered no matching profile, the slot
    read as though nothing were bound, and choosing one changed nothing the
    user could see. Both symptoms are one rule.

    A calibration index can mean two different profiles on a dual-nozzle
    machine -- on the maintainer's H2C, index 16 is the left hotend's black
    PLA at K=0.018 and 15 is the right's at K=0.020 -- so the index is
    resolved against the slot's own hotend, and a miss shows nothing rather
    than the other nozzle's number. That is right whenever the printer files
    its calibrations per hotend. This one files them per filament: the second
    AMS's slots point at the same entries as the first, every entry tagged
    with one extruder, and requiring a match found nothing at all.

    The hotend now has to appear in the table the printer actually sent
    before it is used to narrow anything. Where it does not, the index stands
    on its own, which is what BambuStudio does for this same card --
    AMSItem.cpp resolves it through get_pa_k_n_value_by_cali_idx, matching
    cali_idx and nothing else. Where it does, nothing changes: the H2C case
    still blanks rather than borrowing, and the other hotend's profiles stay
    reachable under Other K profiles. The relaxed path still refuses an
    answer when the candidates disagree on a value.

    The premise that the table is always numbered per nozzle had been written
    into three comments and two layers of code; it is corrected where it
    appears.

    Alongside it, in the same picker: the K-profile options rendered the
    hotend suffix twice in the matching group and three times under Other, so
    every option on a dual-nozzle printer read "... . Left . Left".
2026-09-20 13:31:43 +02:00
maziggy 2b8caa8bbe fix(queue): chain the Timeline in the scheduler's order, not queue position (issue #3043)
Turning Shortest Job First on reordered the pending list and the
    scheduler, and left the Timeline drawing the pre-SJF queue for good. It
    chained each swimlane's bars by queue position alone and was never told
    the setting existed -- so the one view whose whole job is to say when
    each print will run was the one view answering for an order the
    scheduler had no intention of using.

    Bars now chain in the order the scheduler will dispatch: jumped items
    first, then shortest print time with an unknown duration last, then
    position. The starvation guard is visible there too, so a long print
    that has finally come up reads as next rather than staying buried behind
    every short job on the lane.

    Three surfaces claim to show queue order -- the pending list, the "if
    started now" ETA, and the Timeline -- and each had its own copy of the
    comparator, which is how one of them came to be missing a whole clause.
    They now share one, written against the scheduler's ORDER BY.

    Grouping came along with it: the pending list folded a model name down
    to its first character to build a lane key, so Any X1C and Any X2D both
    landed on -88 (as did Any P1S and Any P1P) and the two lanes interleaved
    into a single run of rows.

    Backend untouched. The scheduler was dispatching correctly the whole
    time; only the drawing of it was wrong.
2026-09-20 13:31:04 +02:00
maziggy 4440c95976 fix(queue): skip preheat entirely when no loaded filament wants a chamber (issue #3041)
Preheat & Heat Soak delayed every PLA print by five to seven minutes and
    gave nothing back. The filament map correctly derived a chamber target of
    0, and the chamber phase correctly skipped -- but the stage then heated
    the bed, waited for it, and held the full soak anyway, because the soak
    had no idea it was holding for a chamber nobody asked for. The print's
    own G-code sets the bed the moment it starts, so the bed phase only moved
    the warm-up ahead of the FTP upload instead of overlapping with it.

    A 0 that comes out of the filament map now skips the stage before any
    command goes out. The one thing the skip still does is put the airduct
    flap back to cooling on the models that have one -- an H2D left in
    heating mode by the ABS job before it would otherwise cook the PLA that
    follows, and that costs one MQTT command and no waiting.

    Explicit instructions are untouched. A chamber target of 0 typed into a
    print's own override still heats the bed and runs the soak, which is what
    the queue documentation has always promised it does, as does forcing a
    print's Preheat override to On. Prints that want chamber heat are
    unaffected, including the P1S/P1P/A1 tier where the bed and the soak
    timer are the whole mechanism.

    The existing unit tests all ran with soak_seconds=0, which is why the
    production default was never exercised; the PLA test now runs at the real
    default and asserts nothing is dispatched and nothing is slept.

    Surfaced in the UI on the way through: the Settings hint claimed the
    derived 0 skipped "the chamber phase", and the per-print chamber override
    field said nothing about a typed 0 meaning bed-only -- a user reaching
    for 0 to turn preheat off got the delay instead.
2026-09-20 13:30:36 +02:00
maziggy ad09406672 fix(slicer): strip zero-valued filament-index sentinels, and sanitise the preview slice too (issue #3030)
Bambu Studio writes 0 into wall_filament, sparse_infill_filament and
    solid_infill_filament to mean "use whichever filament the object is set
    to". Bambu Studio and OrcaSlicer 2.4 define these min 0 and accept it;
    OrcaSlicer 2.3 and earlier used the 1-based scheme (min 1, default 1)
    and reject it with "0 not in range [1.000000,...]". Sidecar images are
    version-tagged, so an install can be pinned to one of those builds.

    Same shape as the -1 inherit markers from #1201 with a different marker,
    so the allowlist becomes a key-to-marker map rather than one global
    constant. The buckets must not bleed: a -1 on a filament index is a real
    value, and a 0 on a raft field is a setting the user chose.

    The key is removed rather than rewritten, which is what makes it safe on
    every build. The CLI then uses its own default: 0 where 0 was legal
    (unchanged), 1 on the older builds, which is what "the active filament"
    means under that scheme.

    The preview slice never ran the sanitiser at all, so a file that sliced
    fine could still fail its automatic plate preview and fall back to the
    painted-face heuristic. It matters more there than in a real slice: the
    preview runs on the file's own embedded settings, so there is no
    --load-settings pass that could supply a replacement for a field the
    range validator has already rejected. That also explains the reported
    "same error on a later attempt of an unchanged file" without any second
    copy of the keys -- the validator that emits it reads the merged global
    config, which per-object model_settings.config overrides never reach.

    The sanitiser moves to utils/threemf_tools so the service can use it
    without importing a route module, and both preview callers pick it up
    from one place. Drops _strip_3mf_embedded_settings and its constant,
    which have had no callers since the strip-everything experiment was
    reverted.
2026-09-20 13:30:11 +02:00
maziggy f3dddf3f5b fix(slicer): offer the desktop handoff only for formats the target slicer accepts (issue #3029)
The Slice action and Open in Slicer offered .stl, .step and .stp
    alongside .3mf, on the assumption that a slicer which opens an STL from
    its own File menu will open one from a link. Bambu Studio does not.
    Every bambustudio:// and bambustudioopen:// URL reaches one import path
    that refuses any filename which is not .3mf, and refuses it before
    fetching anything: "Download failed, unknown file format." The message
    names the format, so the failure reads as a broken model rather than an
    unsupported handoff.

    OrcaSlicer has no such limit. Only MakerWorld links take its equivalent
    path; a link to the user's own Bambuddy goes to its general downloader,
    which does not inspect the extension.

    So the format list becomes per slicer. isSliceableFilename and
    isSliceableFileType take the target as a required argument -- a default
    would quietly reintroduce the handoff that cannot work -- and an
    unrecognised value falls back to the Bambu Studio list, which is where
    openInSlicer sends anything that is not exactly 'orcaslicer'.

    The File Manager offers Slice only when the configured desktop slicer
    will take the file. The 3D preview reaches both slicers from one split
    button, so instead of hiding it promotes whichever can take the file to
    the primary action, naming that slicer when it is not the configured
    one; the split collapses to a plain button when no alternative is left.

    The sidecar path is untouched: with Use Slicer API on, Slice still takes
    STL and 3MF whatever the desktop target is.
2026-09-20 13:29:49 +02:00
maziggy 417d03d174 fix(slicer): keep protocol-handler download tokens valid for their whole TTL (issue #3029)
The Slice and Open in Slicer actions mint a short-lived token and put it in
    the URL, because a protocol handler cannot carry an Authorization header.
    That token was spent by the first request to reach the endpoint, which made
    the handoff depend on the slicer fetching the URL exactly once. Nothing
    guarantees that: Bambu Studio's downloader retries three times after a
    failed attempt, transfers get resumed, on-access scanners fetch. The first
    request won and the slicer was handed a 403.

    verify_slicer_download_token takes a keyword-only single_use flag. The
    default still consumes via DELETE...RETURNING; single_use=False verifies
    with a SELECT and leaves the row for the rest of its five-minute TTL. The
    stored row is the same either way, so the endpoint decides, not the mint.

    The three protocol-handler downloads pass single_use=False: a library file,
    an archive's sliced 3MF, an archive's source 3MF. Resource binding and
    expiry are untouched. The two browser downloads keep consuming, because
    what they hand over is itself consumed -- the prepared printer bundle is
    deleted the moment it has been streamed.

    Also: add "/source-dl/" to PUBLIC_API_PATTERNS. Those patterns match by
    substring and the source 3MF route's segment is source-dl, which does not
    contain "/dl/", so with auth enabled the middleware rejected the slicer's
    header-less request before the route's token check ran. Open source 3MF in
    slicer could never work on an install with authentication on.
2026-09-20 13:29:25 +02:00
maziggy e1fad9d68f fix(auth): decouple media routes from the camera stream token (issue #3025)
Thirteen routes with nothing to do with a camera took the camera stream
    token as their credential -- library and archive thumbnails, plate
    previews and plate thumbnails, timelapses, print photos, archive QR
    codes, project covers, print-log thumbnails, printer covers and
    external-link icons. A browser cannot put an Authorization header on an
    <img src>, so these need a credential that fits in the URL, and the
    camera token was the only one that existed. Minting one costs
    camera:view, so a user granted library access to their own files got a
    grid of broken images until they were also handed the live camera.

    Adds a media token: minted by POST /auth/media-token behind plain
    authentication, and identified -- it records the principal the way the
    websocket token does rather than being anonymous the way the camera
    token is. Each route now gates on the permission and ownership rules of
    the resource it serves, through the same _ensure_*_visible helpers its
    header-authenticated siblings already use. The three camera routes keep
    the camera token, and require_camera_stream_token_if_auth_enabled now
    documents that it is for those only.

    The media dependencies accept ordinary Authorization / X-API-Key headers
    as well as ?token=, delegating that path to the existing checkers, so
    API-key scope rules and the per-printer allowlist are unchanged.

    Long-lived camera_stream, camwall and overlay tokens are deliberately
    not accepted on the media routes -- those are handed to kiosks, walls
    and Home Assistant to display video. The cam wall, streaming overlay and
    kiosk views use only the three camera routes and are unaffected.

    Frontend: withMediaToken alongside withStreamToken, and
    useStreamTokenSync fetches a media token for every signed-in user while
    asking for a camera token only when the user can mint one, which also
    stops the 403 that fired on every page load for everyone else.

    Also fixed, same class:
    - /printers/{id}/files/plate-thumbnail/{i} is rendered in an <img> but
      had a header-only guard, so the file manager's plate thumbnails 401'd
      whenever auth was enabled. It now takes a media token too.
    - getProjectCoverImageUrl returned a URL ending in ?token=, and the
      project edit dialog appended its own ?v= cache-buster after it, so the
      second ? landed inside the token value. The version is now a parameter
      applied before the token.

    Tests: 15 integration tests for the token boundary, permission
    enforcement and per-row scoping; 10 frontend tests for the URL split and
    the two-query hook. test_cover_image_get_uses_stream_token_gate is
    renamed and repointed at the media gate -- what it pins, that the
    credential has to fit in a URL, is unchanged.
2026-09-20 13:28:50 +02:00
maziggy ef6446d30a fix(auth): let the sidebar read install flags without settings:read (issue #3023)
cost_centers:read_own exists so a non-admin can see their own wallet, balance
    and cost-centre spend, and the Finance page honoured it -- typing the URL
    worked and rendered their balance. The sidebar never offered the entry.

    It decides whether to show Finance by reading billing_enabled from
    GET /settings, which requires SETTINGS_READ. A non-admin gets 403 there, so
    the value arrived undefined, `undefined !== true` held, and the entry was
    hidden from precisely the users the permission was written for. The permission
    map and the route guard were both already right; only discovery was broken.

    Three more fields came from that same 403, and one of them failed the other way
    up. The Notifications gate tests `=== false`, which undefined never satisfies,
    so an administrator who switched user notifications off still left the entry
    showing to the non-admins it governs. Nobody reported that one, and no
    administrator could have reproduced either: administrators can read /settings.
    The remaining two were quieter -- the sponsor prompt fell back to EUR whatever
    the install uses, and the update check ran where it had been turned off.

    SETTINGS_READ cannot be the price of knowing whether billing is on. It also
    grants sight of the SMTP, LDAP and MQTT credentials, which is the reason
    /settings/ui-preferences exists at all.

    So: a second endpoint, GET /settings/ui-flags, carrying those four fields and
    asking only that the caller be signed in, via the existing
    require_auth_if_enabled. Layout drops its /settings query altogether, which
    closes the class rather than the two instances that happened to be visible.

    Deliberately not four more fields on /ui-preferences. That endpoint is served
    to anyone at all on the recorded grounds that its contents are "public defaults
    that ship with the app" (test_route_auth_coverage.py), and its field set is
    pinned by a test written to make anyone adding to it stop and think. These
    fields are not defaults -- they say how this deployment is configured -- so
    they get their own endpoint at their own trust level instead of stretching that
    charter to fit them. require_auth_if_enabled also keeps the auth-disabled case
    that /ui-preferences was ungated for: "works when there is no auth" and
    "readable by anyone" are different statements, and conflating them is what put
    a settings read in front of a permission that never needed one.

    Twelve tests. Backend pins that the operator can read the flags, that the same
    operator still gets 403 from /settings, that an anonymous caller is refused
    when auth is on, that it answers when auth is off, the exact field set, that no
    credential ever appears, and that the public endpoint did not quietly gain
    these fields. Frontend pins Finance visible for cost_centers:read_own with
    /settings returning 403, and Notifications hidden when the flag is off -- each
    waiting on a positive signal before asserting an absence, so the negative cases
    cannot pass before the query resolves.

    Reported by @lonix, who traced it to the queryKey and the route gate.
2026-09-20 13:28:17 +02:00
maziggy 23a6633f42 fix(queue): say when an unscheduled item runs instead of calling it ASAP (issue #3018)
The print dialog offers ASAP, Queue and Schedule. ASAP and Queue differ only
    in where the item is inserted, and neither is stored on the item -- scheduleType
    is a frontend-only concept, and grep finds no "asap" anywhere in the backend. So
    the queue's time column had nothing to read but scheduled_time, and labelled
    every unscheduled item "ASAP": the name of the one mode the user may well have
    chosen against.

    Someone who picked Queue then watched their row appear as ASAP and start
    immediately, and concluded Bambuddy had overridden them. Two reporters wrote
    that same sentence thirteen months apart, and #2557 was closed as A2L-specific
    after the first of them -- kilrah replied there with an X1C before filing this.

    The column answers when an item runs, so it now says that. The key is renamed
    whenFree rather than just retranslated: left called asap, the next translator
    puts ASAP back.

    The dispatch is unchanged, because it was right. A print scheduled for later
    does not reserve the printer until then; an unscheduled item behind it uses the
    idle printer rather than leaving an X1C dark until 6 AM. Two of the new tests
    pin that, so it does not get "fixed" later on the strength of a report like this
    one.

    What genuinely could not answer the question was the queue's own log. Its
    per-printer line called every entry in busy_printers "not available" -- but that
    set holds both printers that cannot take work and printers the pass has just
    claimed for some, which are opposite facts. It also read printer state at
    logging time rather than at the decision, so #3018's bundle carries

        Queue: printer 1 not available — connected=True, state=IDLE, ...
        Launching 1 upload(s) (pool 0/4 in flight)
        Starting queue item 18

    a printer reported unavailable, evidence that it was available, and a dispatch
    to it, in three consecutive lines. It is the first line anyone greps for "why
    did my item not go out".

    Each of the nine sites that removes a printer from a pass now records why, and
    the summary reports a claim as a reservation and everything else as an
    obstruction with its reason. The live fields stay, since a bundle reader wants
    them next, but are labelled as read now rather than offered as the cause.
    print_scheduler.py:1210 already documented that these two meanings differ -- the
    dispatching_printers snapshot exists for it. This carries that distinction into
    the log.
2026-09-20 13:27:43 +02:00
maziggy eab55cef75 fix(archives): report a refused FTPS handshake as the printer, not the slicer (issue #2780)
The Archives banner picks its wording from a priority list of the causes it
    knows. REASON_FTPS_COOLOFF was added by #2957 and never put in that list, so an
    install whose empty archives all came from a printer refusing the TLS handshake
    matched nothing, got reason: null, and fell to the original wording: the slicer
    did not leave the .gcode.3mf on the card, switch on "Store sent files on
    external storage", here is installation step 4.

    Every clause of that is wrong for this cause. The slicer did write the file --
    reason he read the whole thing as Bambuddy being broken. The setting was
    already on. And there is nothing on his side to change: the printer's file
    service answered port 990 with something that is not TLS, so no lookup ever
    ran and where the file went was never tested. It is #2899's mistake -- an
    error message describing a cause that was ruled out before it was printed --
    in a surface that did not get that pass.

    The slug now leads the list rather than joining the end of it. The other three
    describe an install working as configured and each ends in something the
    operator can change; this one reports a fault nobody can yet explain, which is
    both the more urgent thing to say and the thing that produces a useful report.
    The banner also dismisses one-shot into localStorage, so a reason ranked below
    another is not deferred to next time -- it is never shown to that user again.
    Ranking it first cannot bury a permanent cause in exchange: a successful
    recovery clears the row's markers (#2957), so a row still carrying this slug is
    one whose retry failed too, days after the print.

    New wording in all fourteen languages says the printer refused the connection,
    that this is not a slicer setting and not something the operator did, that
    Bambuddy comes back for the file when the five-minute pause clears so a brief
    episode fills itself in, and that a card still empty means the refusal outlasted
    the retry. It links to the handshake entry in the troubleshooting guide instead
    of to the installation guide.

    The client's getNo3MFWarning type still declared the old three-slug union, which
    made all three new comparisons provably dead -- caught by tsc, not by any test.

    Four tests. One pins the slug reaching the banner, one pins it outranking the
    three settled causes, one pins those three keeping their order behind it, and
    one asserts the rendered wording carries no slicer advice at all.

    Also corrects the wiki page these reports are pointed at. It said to power-cycle
    the printer; the reporter who prompted that advice power-cycled both of his and
    the failure continued unchanged, and bambu_ftp.py has carried the retraction in
    a comment since. The page now states what was actually measured -- that a
    version mismatch reports itself differently, that every printer probed refuses
    TLS 1.3 and completes on 1.2 so there is no version to fall back from, and that
    three P2S units failed while three more on the same switch never did -- says
    plainly that the trigger is unknown, and names the one cleartext-probe line
    worth collecting.
2026-09-20 13:27:10 +02:00
maziggy 58df1cb866 feat(ftp): log how every FTP session closes (issue #3009)
disconnect() and _abandon_connection() logged nothing, at any level. A
    session closed cleanly and a socket genuinely abandoned therefore produced
    identical output -- none -- and the only way to tell them apart was to read
    the source.

    That is how #3009 was filed. Its trace shows a print completion opening two
    FTP connections, deleting one file, and then nothing until the printer was
    powered off 21 minutes later, read as connections left open and offered as a
    mechanism for the 0500-C010 SD-card error that #645 has been chasing since
    April. The two connections are the post-print SD cleanup in main.py walking
    its candidate filenames, each through delete_file_async, which closes in a
    finally; running that against the mock FTPS server shows the server logging
    "FTP session closed (disconnect)" for both the 250 and the 550, holding zero
    sessions afterwards. Nothing in a support bundle could have shown that.

    Both close paths now log one DEBUG line: the printer, whether QUIT was
    acknowledged or the socket had to be dropped without it, why, and how long
    the session was held. Every connect in a debug log now has a matching close.

    The duration comes from a stamp taken when the control socket opens rather
    than after login, so a session that dies during login is accounted for too;
    where no socket was ever established the line says "held unknown" rather
    than claiming a number. The four connect() failure paths pass their own
    reason, so a close line stands on its own next to the warning above it.

    Nine tests, seven of which fail against the unlogged version. The other two
    assert silence -- a bare disconnect(), and a connect skipped by the handshake
    cool-off -- where no socket was opened and a close line would pair with no
    connect.
2026-09-20 13:26:42 +02:00
maziggy 8186ef817e fix(orca-cloud): close the HTTP client when an authenticated build fails
OrcaCloudService owns an httpx client from construction, and every path in
    _build_authenticated_service after that point can raise: no stored refresh
    token, a rejected refresh, an unreachable Orca, and the token-rotation write.
    On success the caller closes the client. On failure nobody is ever handed it,
    so all four paths leaked one into the connection pool.

    That went unnoticed while the only callers were routes, where the trigger is a
    person retrying a broken sign-in a handful of times. It stopped being harmless
    in 9434875f, which added a caller in spool assignment -- one build per
    Orca-referenced spool, failing on every assignment for as long as the stored
    credentials cannot be refreshed.

    The unwind guard catches BaseException rather than Exception: a cancelled
    request leaks the client just as surely as a failed refresh, and cancellation
    during shutdown is exactly when dangling sockets are least welcome. The close
    inside it is guarded in turn, so a failing cleanup cannot replace the error the
    caller needs to see -- least of all a CancelledError, which has to keep
    propagating for cancellation to work at all.

    Six tests. Four fail against the unguarded builder, verified by reverting the
    guard and re-running; the other two pin the surrounding contract (a failing
    close must not mask the real error, and a successful build must leave the
    client open for its caller) and pass either way. The shared _expired_service
    helper now gives the mock an awaitable close(), so the four pre-existing
    refresh tests exercise the same path.

    Also corrects two comments and the changelog entry from 9434875f, which
    overstated what the captures support. They claimed Bambu Cloud returns a
    preset's filament_id in either of two places and only one was read. The
    responses recorded in #1053 show something narrower: a Studio-created preset
    carries it on the envelope, and an Orca-created one has none at all -- the
    envelope says null and `setting` is a delta from the base. The `setting` lookup
    stays as belt-and-braces for a shape no captured response has needed yet, but
    it is not why a custom profile reached the slicer as its base. That is the
    OrcaSlicer preset format having no filament_id field, filed upstream as
    OrcaSlicer PR #13315.

    The eight-character truncation is now evidenced across three models rather than
    one -- an A1 storing PFUS9DDC of PFUS9DDC938FE3AB8F, a P1S storing PFUS7A65 of
    PFUS7A65290D3DADC4, and an H2D storing 8219C45D of an Orca profile UUID.
2026-09-20 13:26:02 +02:00
maziggy a3e6fae5cb fix(ams): resolve a custom filament's own id from every preset source (issue #3003)
A custom filament profile reaches an AMS slot as itself through exactly one
    field, tray_info_idx, and every source we can read that id from was reading it
    from the wrong place or not reading it at all.

    Bambu Cloud returns a preset's own filament_id either on the response envelope
    or inside the preset JSON under `setting`, and only the envelope was read.
    Presets of the second shape fell through to the base_id branch and reached the
    slicer as the Bambu filament they inherit from. filament_type next door already
    handled both spreads; filament_id now does too.

    Orca Cloud was absent from the resolver entirely. A spool stores the bare
    profile UUID, which matched no branch and fell through normalize_slicer_filament
    -- a function that passes anything it does not recognise straight through -- so
    a 36-character UUID went into the field. Orca profiles carry their own
    filament_id in the slicer JSON that OrcaProfileDetail already exposes under
    `setting`, so the lookup is the same one the Bambu branch does. It is
    best-effort: no pairing, a dead token or a missing orca_cloud:auth permission
    degrades to the fallback rather than failing the assignment, and it passes
    clear_on_auth_failure=False because a background caller cannot tell a real
    revocation from a lost refresh-rotation race.

    configure_ams_slot sent the cloud setting_id as tray_info_idx when it found no
    real filament id. That field is 8 characters on the printer -- exactly the width
    of a local preset id, less than half a cloud one. Measured on the reporter's A1:
    sent PFUS9ddc938fe3ab8f, the tray read back PFUS9DDC, acknowledged as a success.
    The slot then resolved to nothing, so the slicer showed Generic anyway and the
    calibration table, keyed by the same field, lost the slot. It now falls back to
    the slot's existing filament id or the generic for the material, and the route's
    guard was aligned with the resolver's so both refuse the same four shapes from
    one shared definition.

    This reverses the contract #1053 pinned. Six tests asserted that the PFUS
    belonged in tray_info_idx; the A1 capture shows it never worked, so they were
    rewritten with the measurement in their docstrings.

    Verified against 874 AMS trays across twelve models in the support archive: 92
    already carry a custom "P" + 7 hex filament id, which is what confirms the
    mechanism works and this is a lookup failure rather than a platform limit. No
    tray on any model carries a setting_id, so a profile with no filament_id of its
    own still cannot be told apart from its base.
2026-09-20 13:25:27 +02:00
maziggy 9fda74579f fix(ui): make hover:text-white theme-aware so light-theme labels survive hover (issue #1909) 2026-09-20 13:25:05 +02:00
maziggy 0cbda2a7f0 Updated BACKERS 2026-09-20 13:24:44 +02:00
maziggy 0ed86c9802 (security): Bumped fflate to 0.8.3 for a denial-of-service advisory reachable through three's compressed-format loaders (GHSA-px8p-9vwx-vf98, #3034) 2026-09-20 13:24:29 +02:00
maziggy 9fed80073d Merge pull request #3034 from maziggy/dependabot/npm_and_yarn/frontend/npm_and_yarn-c46cce24cb 2026-09-20 13:24:13 +02:00
maziggy 7b507103e0 deps(frontend): bump browserslist and @humanfs/node for dev-scope advisories 2026-09-20 13:23:50 +02:00
maziggy 3e6896de2d deps(frontend): move the Tiptap stack to 3.31.1
GHSA-cp6q-959q-f8rh: @tiptap/core's mergeAttributes() copies keys out of
    Object.entries() with plain bracket assignment, so an own __proto__ key
    from JSON hits the legacy prototype setter rather than writing a
    property. The result carries an attacker-controlled prototype while
    Object.keys() and own-property checks show nothing, and ProseMirror's
    DOMSerializer.renderSpec() enumerates attribute objects with for...in --
    so inherited src and onerror land on a rendered <img> and execute.
    Medium, CVSS 4.0 6.4, fixed in 3.30.4.

    Not reachable here. The advisory needs an untrusted object arriving at
    mergeAttributes(), or a custom or dynamic extension that preserves the
    attribute object. Nothing under frontend/src calls mergeAttributes or
    defines an extension, and RichTextEditor builds a fixed schema from
    StarterKit plus six stock extensions whose HTMLAttributes are static
    literals. Content crosses as an HTML string rather than JSON, so no own
    __proto__ key reaches an attrs object at all -- the DOM parser only
    fills attributes the schema declares -- and every read-only render is
    sanitized.

    Lockfile only: package.json already declared ^3.11.1, so the patched
    line was inside the range and only the stale lock held 3.19.0. No
    overrides entry needed.

    @tiptap/pm has narrowed its dependency set, so prosemirror-markdown,
    prosemirror-menu, prosemirror-collab, prosemirror-schema-basic,
    prosemirror-trailing-node, markdown-it and linkify-it leave the tree --
    16 packages, none imported by this repo. That retires the reachability
    note carried for linkify-it in 1.2.5.

    eslint, build with the Safari 16 baseline check, i18n parity and 3514
    frontend tests across 256 files all pass. npm audit --omit=dev, which
    is what CI gates on, reports zero vulnerabilities.
2026-09-20 13:23:32 +02:00
maziggy 7490c93081 ci: balance the backend test shards by measured time, not test count
Backend Tests (shard 1/4) timed out after 10 minutes on e8b901f54
    ("Updated BACKERS"), a docs-only commit. The step was not hung: it
    reached 98%, every test passing, and was killed about 12 seconds short
    of finishing.

    pytest-split balances by duration only when it has a durations file,
    and there was none. Without one it splits by test COUNT -- 2926 / 2926
    / 2926 / 2925, exactly 25% each, which is what the comment here claimed
    was good enough. Count is not time. Measured over the full suite
    (11703 tests, 1045.6s):

        shard 1   2926 tests   658.2s
        shard 2   2926 tests   173.1s
        shard 3   2926 tests   113.3s
        shard 4   2925 tests   101.1s

    Shard 1 was carrying 63% of the suite's runtime -- a 6.5x spread -- and
    had been walking toward the cap for weeks: 475s, 404s, 445s, then 587s
    on 08-30, thirteen seconds under it, and 613s here. CI printed the
    reason in its own log on every run: "[pytest-split] No test durations
    found."

    Commit tests/.test_durations and pass --durations-path explicitly:

        shard 1   1273 tests   261.6s
        shard 2   1070 tests   261.5s
        shard 3   1522 tests   262.0s
        shard 4   7838 tests   260.6s

    A 1.01x spread. The lopsided test counts are the point: shard 4 takes
    thousands of fast unit tests, shard 1 keeps the slow integration ones.
    The four groups still partition the suite exactly -- union is 11703
    with nothing dropped or duplicated, and all four pass.

    --durations-path has to be explicit because pytest-split defaults it to
    $CWD/.test_durations, and the two matrices run from different
    directories: the native job from backend/, the Docker job from the
    image root. Left implicit, the Docker one finds nothing and silently
    falls back to the count split. Node IDs match across both because
    backend/tests/pytest.ini pins rootdir to backend/tests either way, so a
    single file serves them both; verified the file clears .dockerignore
    and lands at /app/backend/tests/.test_durations at full size.

    timeout-minutes 10 -> 15 for headroom, since the file goes stale as
    tests are added. Staleness degrades slowly rather than breaking --
    unknown tests are treated as average.
2026-09-20 13:22:17 +02:00
maziggy 35405dd79e Updated BACKERS 2026-09-20 13:21:53 +02:00
maziggy 5584dca898 Bumped version 2026-09-20 13:19:02 +02:00
maziggy 0b830ac35c fix(ams): stop reading the printer's command acks as status (issue #3040)
Every project_file carried "cfg": "0" — the device-config bitmask, which
Bambu Studio has never sent and the firmware ignores. The printer echoes a
command's fields back in its ack, and the ack was ingested as telemetry, so
bit 18 read as "AMS Filament Backup off" 25 ms after every dispatch.

Families that repeat cfg in their periodic status (P2S, H2C, X2D) corrected
themselves a second later; the P1S, A1, A1 Mini and A2L send it only in a
full status dump, so the wrong value stuck and silently disabled the
prefer-lowest-remaining gate. The A1 family, which reports no cfg at all and
is meant to stay "unknown", was pinned to a definite "off".

Acks are no longer read as status, for the backup bit or the per-job
timelapse flag they also echo, and cfg is gone from the print command.
2026-09-16 09:52:24 +02:00
maziggy 6017981e62 Updated BACKERS and SPONSORS 2026-09-14 10:36:24 +02:00
maziggy c116bb8d21 chore(deps): bump vitest to 4.1.11 for the mocker path-traversal advisory
@vitest/mocker registers a redirect mock's target without checking it
against Vite's file-serving allowlist, and the load hook then returns
readFile(mock.redirect) as the module source. The target is built as
join(root, new URL(redirect).pathname), which confines nothing -- a
non-special scheme keeps ".." in pathname, so the join resolves outside
the project root. GHSA-82fw-gwwq-j7x9, CVSS 5.9, CWE-22.

Not reachable here. The unauthenticated path is the public mockerPlugin
and interceptorPlugin exports, which attach to Vite's unauthenticated HMR
socket for the benefit of third-party dev servers; nothing under
frontend/src imports either. Browser mode, which registers over a
token-authenticated RPC, is not installed -- @vitest/browser is an unmet
optional peer -- and vitest.config.ts runs plain jsdom, so no dev server
listens during a test run. Both packages are devDependencies and reach no
shipped artifact.

vitest and the eight @vitest/* packages go 4.1.8 -> 4.1.11, carrying
es-module-lexer, expect-type, obug, std-env, tinyexec and tinyrainbow.
Fifteen lockfile entries, all dev-scoped, none added or removed. The
^4.1.8 range already admitted the fix, but the declared floor is raised
so a regenerated lockfile cannot resolve back beneath it. No src change,
so the bundle is byte-identical and static/ does not move.
2026-09-14 10:12:53 +02:00
maziggy 309e64b8a2 fix(ams): resolve a slot's K profile by index when the printer does not file per hotend (issue #3044)
An X2D with two AMS 2 Pro, one per hotend, showed a K value on every slot
of the first and nothing on any slot of the second. Configure Slot was
worse than blank there: the picker offered no matching profile, the slot
read as though nothing were bound, and choosing one changed nothing the
user could see. Both symptoms are one rule.

A calibration index can mean two different profiles on a dual-nozzle
machine -- on the maintainer's H2C, index 16 is the left hotend's black
PLA at K=0.018 and 15 is the right's at K=0.020 -- so the index is
resolved against the slot's own hotend, and a miss shows nothing rather
than the other nozzle's number. That is right whenever the printer files
its calibrations per hotend. This one files them per filament: the second
AMS's slots point at the same entries as the first, every entry tagged
with one extruder, and requiring a match found nothing at all.

The hotend now has to appear in the table the printer actually sent
before it is used to narrow anything. Where it does not, the index stands
on its own, which is what BambuStudio does for this same card --
AMSItem.cpp resolves it through get_pa_k_n_value_by_cali_idx, matching
cali_idx and nothing else. Where it does, nothing changes: the H2C case
still blanks rather than borrowing, and the other hotend's profiles stay
reachable under Other K profiles. The relaxed path still refuses an
answer when the candidates disagree on a value.

The premise that the table is always numbered per nozzle had been written
into three comments and two layers of code; it is corrected where it
appears.

Alongside it, in the same picker: the K-profile options rendered the
hotend suffix twice in the matching group and three times under Other, so
every option on a dual-nozzle printer read "... . Left . Left".
2026-09-07 19:23:48 +02:00
maziggy f5cdc86689 fix(queue): chain the Timeline in the scheduler's order, not queue position (issue #3043)
Turning Shortest Job First on reordered the pending list and the
scheduler, and left the Timeline drawing the pre-SJF queue for good. It
chained each swimlane's bars by queue position alone and was never told
the setting existed -- so the one view whose whole job is to say when
each print will run was the one view answering for an order the
scheduler had no intention of using.

Bars now chain in the order the scheduler will dispatch: jumped items
first, then shortest print time with an unknown duration last, then
position. The starvation guard is visible there too, so a long print
that has finally come up reads as next rather than staying buried behind
every short job on the lane.

Three surfaces claim to show queue order -- the pending list, the "if
started now" ETA, and the Timeline -- and each had its own copy of the
comparator, which is how one of them came to be missing a whole clause.
They now share one, written against the scheduler's ORDER BY.

Grouping came along with it: the pending list folded a model name down
to its first character to build a lane key, so Any X1C and Any X2D both
landed on -88 (as did Any P1S and Any P1P) and the two lanes interleaved
into a single run of rows.

Backend untouched. The scheduler was dispatching correctly the whole
time; only the drawing of it was wrong.
2026-09-07 18:56:03 +02:00
maziggy 069ee8fc87 fix(queue): skip preheat entirely when no loaded filament wants a chamber (issue #3041)
Preheat & Heat Soak delayed every PLA print by five to seven minutes and
gave nothing back. The filament map correctly derived a chamber target of
0, and the chamber phase correctly skipped -- but the stage then heated
the bed, waited for it, and held the full soak anyway, because the soak
had no idea it was holding for a chamber nobody asked for. The print's
own G-code sets the bed the moment it starts, so the bed phase only moved
the warm-up ahead of the FTP upload instead of overlapping with it.

A 0 that comes out of the filament map now skips the stage before any
command goes out. The one thing the skip still does is put the airduct
flap back to cooling on the models that have one -- an H2D left in
heating mode by the ABS job before it would otherwise cook the PLA that
follows, and that costs one MQTT command and no waiting.

Explicit instructions are untouched. A chamber target of 0 typed into a
print's own override still heats the bed and runs the soak, which is what
the queue documentation has always promised it does, as does forcing a
print's Preheat override to On. Prints that want chamber heat are
unaffected, including the P1S/P1P/A1 tier where the bed and the soak
timer are the whole mechanism.

The existing unit tests all ran with soak_seconds=0, which is why the
production default was never exercised; the PLA test now runs at the real
default and asserts nothing is dispatched and nothing is slept.

Surfaced in the UI on the way through: the Settings hint claimed the
derived 0 skipped "the chamber phase", and the per-print chamber override
field said nothing about a typed 0 meaning bed-only -- a user reaching
for 0 to turn preheat off got the delay instead.
2026-09-07 16:38:36 +02:00
maziggy 9a837d19a1 fix(slicer): strip zero-valued filament-index sentinels, and sanitise the preview slice too (issue #3030)
Bambu Studio writes 0 into wall_filament, sparse_infill_filament and
solid_infill_filament to mean "use whichever filament the object is set
to". Bambu Studio and OrcaSlicer 2.4 define these min 0 and accept it;
OrcaSlicer 2.3 and earlier used the 1-based scheme (min 1, default 1)
and reject it with "0 not in range [1.000000,...]". Sidecar images are
version-tagged, so an install can be pinned to one of those builds.

Same shape as the -1 inherit markers from #1201 with a different marker,
so the allowlist becomes a key-to-marker map rather than one global
constant. The buckets must not bleed: a -1 on a filament index is a real
value, and a 0 on a raft field is a setting the user chose.

The key is removed rather than rewritten, which is what makes it safe on
every build. The CLI then uses its own default: 0 where 0 was legal
(unchanged), 1 on the older builds, which is what "the active filament"
means under that scheme.

The preview slice never ran the sanitiser at all, so a file that sliced
fine could still fail its automatic plate preview and fall back to the
painted-face heuristic. It matters more there than in a real slice: the
preview runs on the file's own embedded settings, so there is no
--load-settings pass that could supply a replacement for a field the
range validator has already rejected. That also explains the reported
"same error on a later attempt of an unchanged file" without any second
copy of the keys -- the validator that emits it reads the merged global
config, which per-object model_settings.config overrides never reach.

The sanitiser moves to utils/threemf_tools so the service can use it
without importing a route module, and both preview callers pick it up
from one place. Drops _strip_3mf_embedded_settings and its constant,
which have had no callers since the strip-everything experiment was
reverted.
2026-09-07 14:37:49 +02:00
maziggy e2493132bf fix(slicer): offer the desktop handoff only for formats the target slicer accepts (issue #3029)
The Slice action and Open in Slicer offered .stl, .step and .stp
alongside .3mf, on the assumption that a slicer which opens an STL from
its own File menu will open one from a link. Bambu Studio does not.
Every bambustudio:// and bambustudioopen:// URL reaches one import path
that refuses any filename which is not .3mf, and refuses it before
fetching anything: "Download failed, unknown file format." The message
names the format, so the failure reads as a broken model rather than an
unsupported handoff.

OrcaSlicer has no such limit. Only MakerWorld links take its equivalent
path; a link to the user's own Bambuddy goes to its general downloader,
which does not inspect the extension.

So the format list becomes per slicer. isSliceableFilename and
isSliceableFileType take the target as a required argument -- a default
would quietly reintroduce the handoff that cannot work -- and an
unrecognised value falls back to the Bambu Studio list, which is where
openInSlicer sends anything that is not exactly 'orcaslicer'.

The File Manager offers Slice only when the configured desktop slicer
will take the file. The 3D preview reaches both slicers from one split
button, so instead of hiding it promotes whichever can take the file to
the primary action, naming that slicer when it is not the configured
one; the split collapses to a plain button when no alternative is left.

The sidecar path is untouched: with Use Slicer API on, Slice still takes
STL and 3MF whatever the desktop target is.
2026-09-07 14:20:39 +02:00
maziggy b9bd312826 fix(slicer): keep protocol-handler download tokens valid for their whole TTL (issue #3029)
The Slice and Open in Slicer actions mint a short-lived token and put it in
the URL, because a protocol handler cannot carry an Authorization header.
That token was spent by the first request to reach the endpoint, which made
the handoff depend on the slicer fetching the URL exactly once. Nothing
guarantees that: Bambu Studio's downloader retries three times after a
failed attempt, transfers get resumed, on-access scanners fetch. The first
request won and the slicer was handed a 403.

verify_slicer_download_token takes a keyword-only single_use flag. The
default still consumes via DELETE...RETURNING; single_use=False verifies
with a SELECT and leaves the row for the rest of its five-minute TTL. The
stored row is the same either way, so the endpoint decides, not the mint.

The three protocol-handler downloads pass single_use=False: a library file,
an archive's sliced 3MF, an archive's source 3MF. Resource binding and
expiry are untouched. The two browser downloads keep consuming, because
what they hand over is itself consumed -- the prepared printer bundle is
deleted the moment it has been streamed.

Also: add "/source-dl/" to PUBLIC_API_PATTERNS. Those patterns match by
substring and the source 3MF route's segment is source-dl, which does not
contain "/dl/", so with auth enabled the middleware rejected the slicer's
header-less request before the route's token check ran. Open source 3MF in
slicer could never work on an install with authentication on.
2026-09-07 14:05:25 +02:00
maziggy 816f073a9e fix(auth): decouple media routes from the camera stream token (issue #3025)
Thirteen routes with nothing to do with a camera took the camera stream
token as their credential -- library and archive thumbnails, plate
previews and plate thumbnails, timelapses, print photos, archive QR
codes, project covers, print-log thumbnails, printer covers and
external-link icons. A browser cannot put an Authorization header on an
<img src>, so these need a credential that fits in the URL, and the
camera token was the only one that existed. Minting one costs
camera:view, so a user granted library access to their own files got a
grid of broken images until they were also handed the live camera.

Adds a media token: minted by POST /auth/media-token behind plain
authentication, and identified -- it records the principal the way the
websocket token does rather than being anonymous the way the camera
token is. Each route now gates on the permission and ownership rules of
the resource it serves, through the same _ensure_*_visible helpers its
header-authenticated siblings already use. The three camera routes keep
the camera token, and require_camera_stream_token_if_auth_enabled now
documents that it is for those only.

The media dependencies accept ordinary Authorization / X-API-Key headers
as well as ?token=, delegating that path to the existing checkers, so
API-key scope rules and the per-printer allowlist are unchanged.

Long-lived camera_stream, camwall and overlay tokens are deliberately
not accepted on the media routes -- those are handed to kiosks, walls
and Home Assistant to display video. The cam wall, streaming overlay and
kiosk views use only the three camera routes and are unaffected.

Frontend: withMediaToken alongside withStreamToken, and
useStreamTokenSync fetches a media token for every signed-in user while
asking for a camera token only when the user can mint one, which also
stops the 403 that fired on every page load for everyone else.

Also fixed, same class:
- /printers/{id}/files/plate-thumbnail/{i} is rendered in an <img> but
  had a header-only guard, so the file manager's plate thumbnails 401'd
  whenever auth was enabled. It now takes a media token too.
- getProjectCoverImageUrl returned a URL ending in ?token=, and the
  project edit dialog appended its own ?v= cache-buster after it, so the
  second ? landed inside the token value. The version is now a parameter
  applied before the token.

Tests: 15 integration tests for the token boundary, permission
enforcement and per-row scoping; 10 frontend tests for the URL split and
the two-query hook. test_cover_image_get_uses_stream_token_gate is
renamed and repointed at the media gate -- what it pins, that the
credential has to fit in a URL, is unchanged.
2026-09-07 13:38:15 +02:00
maziggy 93eeb05264 fix(auth): let the sidebar read install flags without settings:read (issue #3023)
cost_centers:read_own exists so a non-admin can see their own wallet, balance
and cost-centre spend, and the Finance page honoured it -- typing the URL
worked and rendered their balance. The sidebar never offered the entry.

It decides whether to show Finance by reading billing_enabled from
GET /settings, which requires SETTINGS_READ. A non-admin gets 403 there, so
the value arrived undefined, `undefined !== true` held, and the entry was
hidden from precisely the users the permission was written for. The permission
map and the route guard were both already right; only discovery was broken.

Three more fields came from that same 403, and one of them failed the other way
up. The Notifications gate tests `=== false`, which undefined never satisfies,
so an administrator who switched user notifications off still left the entry
showing to the non-admins it governs. Nobody reported that one, and no
administrator could have reproduced either: administrators can read /settings.
The remaining two were quieter -- the sponsor prompt fell back to EUR whatever
the install uses, and the update check ran where it had been turned off.

SETTINGS_READ cannot be the price of knowing whether billing is on. It also
grants sight of the SMTP, LDAP and MQTT credentials, which is the reason
/settings/ui-preferences exists at all.

So: a second endpoint, GET /settings/ui-flags, carrying those four fields and
asking only that the caller be signed in, via the existing
require_auth_if_enabled. Layout drops its /settings query altogether, which
closes the class rather than the two instances that happened to be visible.

Deliberately not four more fields on /ui-preferences. That endpoint is served
to anyone at all on the recorded grounds that its contents are "public defaults
that ship with the app" (test_route_auth_coverage.py), and its field set is
pinned by a test written to make anyone adding to it stop and think. These
fields are not defaults -- they say how this deployment is configured -- so
they get their own endpoint at their own trust level instead of stretching that
charter to fit them. require_auth_if_enabled also keeps the auth-disabled case
that /ui-preferences was ungated for: "works when there is no auth" and
"readable by anyone" are different statements, and conflating them is what put
a settings read in front of a permission that never needed one.

Twelve tests. Backend pins that the operator can read the flags, that the same
operator still gets 403 from /settings, that an anonymous caller is refused
when auth is on, that it answers when auth is off, the exact field set, that no
credential ever appears, and that the public endpoint did not quietly gain
these fields. Frontend pins Finance visible for cost_centers:read_own with
/settings returning 403, and Notifications hidden when the flag is off -- each
waiting on a positive signal before asserting an absence, so the negative cases
cannot pass before the query resolves.

Reported by @lonix, who traced it to the queryKey and the route gate.
2026-09-07 12:48:31 +02:00
maziggy 09b4584d5f fix(queue): say when an unscheduled item runs instead of calling it ASAP (issue #3018)
The print dialog offers ASAP, Queue and Schedule. ASAP and Queue differ only
in where the item is inserted, and neither is stored on the item -- scheduleType
is a frontend-only concept, and grep finds no "asap" anywhere in the backend. So
the queue's time column had nothing to read but scheduled_time, and labelled
every unscheduled item "ASAP": the name of the one mode the user may well have
chosen against.

Someone who picked Queue then watched their row appear as ASAP and start
immediately, and concluded Bambuddy had overridden them. Two reporters wrote
that same sentence thirteen months apart, and #2557 was closed as A2L-specific
after the first of them -- kilrah replied there with an X1C before filing this.

The column answers when an item runs, so it now says that. The key is renamed
whenFree rather than just retranslated: left called asap, the next translator
puts ASAP back.

The dispatch is unchanged, because it was right. A print scheduled for later
does not reserve the printer until then; an unscheduled item behind it uses the
idle printer rather than leaving an X1C dark until 6 AM. Two of the new tests
pin that, so it does not get "fixed" later on the strength of a report like this
one.

What genuinely could not answer the question was the queue's own log. Its
per-printer line called every entry in busy_printers "not available" -- but that
set holds both printers that cannot take work and printers the pass has just
claimed for some, which are opposite facts. It also read printer state at
logging time rather than at the decision, so #3018's bundle carries

    Queue: printer 1 not available — connected=True, state=IDLE, ...
    Launching 1 upload(s) (pool 0/4 in flight)
    Starting queue item 18

a printer reported unavailable, evidence that it was available, and a dispatch
to it, in three consecutive lines. It is the first line anyone greps for "why
did my item not go out".

Each of the nine sites that removes a printer from a pass now records why, and
the summary reports a claim as a reservation and everything else as an
obstruction with its reason. The live fields stay, since a bundle reader wants
them next, but are labelled as read now rather than offered as the cause.
print_scheduler.py:1210 already documented that these two meanings differ -- the
dispatching_printers snapshot exists for it. This carries that distinction into
the log.
2026-09-07 12:22:26 +02:00
maziggy 6564c74071 fix(archives): report a refused FTPS handshake as the printer, not the slicer (issue #2780)
The Archives banner picks its wording from a priority list of the causes it
knows. REASON_FTPS_COOLOFF was added by #2957 and never put in that list, so an
install whose empty archives all came from a printer refusing the TLS handshake
matched nothing, got reason: null, and fell to the original wording: the slicer
did not leave the .gcode.3mf on the card, switch on "Store sent files on
external storage", here is installation step 4.

Every clause of that is wrong for this cause. The slicer did write the file --
reason he read the whole thing as Bambuddy being broken. The setting was
already on. And there is nothing on his side to change: the printer's file
service answered port 990 with something that is not TLS, so no lookup ever
ran and where the file went was never tested. It is #2899's mistake -- an
error message describing a cause that was ruled out before it was printed --
in a surface that did not get that pass.

The slug now leads the list rather than joining the end of it. The other three
describe an install working as configured and each ends in something the
operator can change; this one reports a fault nobody can yet explain, which is
both the more urgent thing to say and the thing that produces a useful report.
The banner also dismisses one-shot into localStorage, so a reason ranked below
another is not deferred to next time -- it is never shown to that user again.
Ranking it first cannot bury a permanent cause in exchange: a successful
recovery clears the row's markers (#2957), so a row still carrying this slug is
one whose retry failed too, days after the print.

New wording in all fourteen languages says the printer refused the connection,
that this is not a slicer setting and not something the operator did, that
Bambuddy comes back for the file when the five-minute pause clears so a brief
episode fills itself in, and that a card still empty means the refusal outlasted
the retry. It links to the handshake entry in the troubleshooting guide instead
of to the installation guide.

The client's getNo3MFWarning type still declared the old three-slug union, which
made all three new comparisons provably dead -- caught by tsc, not by any test.

Four tests. One pins the slug reaching the banner, one pins it outranking the
three settled causes, one pins those three keeping their order behind it, and
one asserts the rendered wording carries no slicer advice at all.

Also corrects the wiki page these reports are pointed at. It said to power-cycle
the printer; the reporter who prompted that advice power-cycled both of his and
the failure continued unchanged, and bambu_ftp.py has carried the retraction in
a comment since. The page now states what was actually measured -- that a
version mismatch reports itself differently, that every printer probed refuses
TLS 1.3 and completes on 1.2 so there is no version to fall back from, and that
three P2S units failed while three more on the same switch never did -- says
plainly that the trigger is unknown, and names the one cleartext-probe line
worth collecting.
2026-09-07 11:26:01 +02:00
maziggy 10f0900fbc feat(ftp): log how every FTP session closes (issue #3009)
disconnect() and _abandon_connection() logged nothing, at any level. A
session closed cleanly and a socket genuinely abandoned therefore produced
identical output -- none -- and the only way to tell them apart was to read
the source.

That is how #3009 was filed. Its trace shows a print completion opening two
FTP connections, deleting one file, and then nothing until the printer was
powered off 21 minutes later, read as connections left open and offered as a
mechanism for the 0500-C010 SD-card error that #645 has been chasing since
April. The two connections are the post-print SD cleanup in main.py walking
its candidate filenames, each through delete_file_async, which closes in a
finally; running that against the mock FTPS server shows the server logging
"FTP session closed (disconnect)" for both the 250 and the 550, holding zero
sessions afterwards. Nothing in a support bundle could have shown that.

Both close paths now log one DEBUG line: the printer, whether QUIT was
acknowledged or the socket had to be dropped without it, why, and how long
the session was held. Every connect in a debug log now has a matching close.

The duration comes from a stamp taken when the control socket opens rather
than after login, so a session that dies during login is accounted for too;
where no socket was ever established the line says "held unknown" rather
than claiming a number. The four connect() failure paths pass their own
reason, so a close line stands on its own next to the warning above it.

Nine tests, seven of which fail against the unlogged version. The other two
assert silence -- a bare disconnect(), and a connect skipped by the handshake
cool-off -- where no socket was opened and a close line would pair with no
connect.
2026-09-07 10:54:55 +02:00
maziggy f3b1c59169 fix(orca-cloud): close the HTTP client when an authenticated build fails
OrcaCloudService owns an httpx client from construction, and every path in
_build_authenticated_service after that point can raise: no stored refresh
token, a rejected refresh, an unreachable Orca, and the token-rotation write.
On success the caller closes the client. On failure nobody is ever handed it,
so all four paths leaked one into the connection pool.

That went unnoticed while the only callers were routes, where the trigger is a
person retrying a broken sign-in a handful of times. It stopped being harmless
in 9434875f, which added a caller in spool assignment -- one build per
Orca-referenced spool, failing on every assignment for as long as the stored
credentials cannot be refreshed.

The unwind guard catches BaseException rather than Exception: a cancelled
request leaks the client just as surely as a failed refresh, and cancellation
during shutdown is exactly when dangling sockets are least welcome. The close
inside it is guarded in turn, so a failing cleanup cannot replace the error the
caller needs to see -- least of all a CancelledError, which has to keep
propagating for cancellation to work at all.

Six tests. Four fail against the unguarded builder, verified by reverting the
guard and re-running; the other two pin the surrounding contract (a failing
close must not mask the real error, and a successful build must leave the
client open for its caller) and pass either way. The shared _expired_service
helper now gives the mock an awaitable close(), so the four pre-existing
refresh tests exercise the same path.

Also corrects two comments and the changelog entry from 9434875f, which
overstated what the captures support. They claimed Bambu Cloud returns a
preset's filament_id in either of two places and only one was read. The
responses recorded in #1053 show something narrower: a Studio-created preset
carries it on the envelope, and an Orca-created one has none at all -- the
envelope says null and `setting` is a delta from the base. The `setting` lookup
stays as belt-and-braces for a shape no captured response has needed yet, but
it is not why a custom profile reached the slicer as its base. That is the
OrcaSlicer preset format having no filament_id field, filed upstream as
OrcaSlicer PR #13315.

The eight-character truncation is now evidenced across three models rather than
one -- an A1 storing PFUS9DDC of PFUS9DDC938FE3AB8F, a P1S storing PFUS7A65 of
PFUS7A65290D3DADC4, and an H2D storing 8219C45D of an Orca profile UUID.
2026-09-07 10:39:10 +02:00
maziggy 9434875fa1 fix(ams): resolve a custom filament's own id from every preset source (issue #3003)
A custom filament profile reaches an AMS slot as itself through exactly one
field, tray_info_idx, and every source we can read that id from was reading it
from the wrong place or not reading it at all.

Bambu Cloud returns a preset's own filament_id either on the response envelope
or inside the preset JSON under `setting`, and only the envelope was read.
Presets of the second shape fell through to the base_id branch and reached the
slicer as the Bambu filament they inherit from. filament_type next door already
handled both spreads; filament_id now does too.

Orca Cloud was absent from the resolver entirely. A spool stores the bare
profile UUID, which matched no branch and fell through normalize_slicer_filament
-- a function that passes anything it does not recognise straight through -- so
a 36-character UUID went into the field. Orca profiles carry their own
filament_id in the slicer JSON that OrcaProfileDetail already exposes under
`setting`, so the lookup is the same one the Bambu branch does. It is
best-effort: no pairing, a dead token or a missing orca_cloud:auth permission
degrades to the fallback rather than failing the assignment, and it passes
clear_on_auth_failure=False because a background caller cannot tell a real
revocation from a lost refresh-rotation race.

configure_ams_slot sent the cloud setting_id as tray_info_idx when it found no
real filament id. That field is 8 characters on the printer -- exactly the width
of a local preset id, less than half a cloud one. Measured on the reporter's A1:
sent PFUS9ddc938fe3ab8f, the tray read back PFUS9DDC, acknowledged as a success.
The slot then resolved to nothing, so the slicer showed Generic anyway and the
calibration table, keyed by the same field, lost the slot. It now falls back to
the slot's existing filament id or the generic for the material, and the route's
guard was aligned with the resolver's so both refuse the same four shapes from
one shared definition.

This reverses the contract #1053 pinned. Six tests asserted that the PFUS
belonged in tray_info_idx; the A1 capture shows it never worked, so they were
rewritten with the measurement in their docstrings.

Verified against 874 AMS trays across twelve models in the support archive: 92
already carry a custom "P" + 7 hex filament id, which is what confirms the
mechanism works and this is a lookup failure rather than a platform limit. No
tray on any model carries a setting_id, so a profile with no filament_id of its
own still cannot be told apart from its base.
2026-09-07 10:15:06 +02:00
maziggy 5ab0a4cb11 fix(ui): make hover:text-white theme-aware so light-theme labels survive hover (issue #1909) 2026-09-07 09:21:01 +02:00
maziggy f879dd552a Updated BACKERS 2026-09-04 14:07:05 +02:00
maziggy 4b857d9e06 (security): Bumped fflate to 0.8.3 for a denial-of-service advisory reachable through three's compressed-format loaders (GHSA-px8p-9vwx-vf98, #3034) 2026-09-04 14:02:56 +02:00
MartinNYHC 3fb41a709a Merge pull request #3034 from maziggy/dependabot/npm_and_yarn/frontend/npm_and_yarn-c46cce24cb 2026-09-04 14:01:17 +02:00
maziggy abf75e836b deps(frontend): bump browserslist and @humanfs/node for dev-scope advisories 2026-09-03 12:29:13 +02:00
maziggy 8f7f18b3c2 deps(frontend): move the Tiptap stack to 3.31.1
GHSA-cp6q-959q-f8rh: @tiptap/core's mergeAttributes() copies keys out of
Object.entries() with plain bracket assignment, so an own __proto__ key
from JSON hits the legacy prototype setter rather than writing a
property. The result carries an attacker-controlled prototype while
Object.keys() and own-property checks show nothing, and ProseMirror's
DOMSerializer.renderSpec() enumerates attribute objects with for...in --
so inherited src and onerror land on a rendered <img> and execute.
Medium, CVSS 4.0 6.4, fixed in 3.30.4.

Not reachable here. The advisory needs an untrusted object arriving at
mergeAttributes(), or a custom or dynamic extension that preserves the
attribute object. Nothing under frontend/src calls mergeAttributes or
defines an extension, and RichTextEditor builds a fixed schema from
StarterKit plus six stock extensions whose HTMLAttributes are static
literals. Content crosses as an HTML string rather than JSON, so no own
__proto__ key reaches an attrs object at all -- the DOM parser only
fills attributes the schema declares -- and every read-only render is
sanitized.

Lockfile only: package.json already declared ^3.11.1, so the patched
line was inside the range and only the stale lock held 3.19.0. No
overrides entry needed.

@tiptap/pm has narrowed its dependency set, so prosemirror-markdown,
prosemirror-menu, prosemirror-collab, prosemirror-schema-basic,
prosemirror-trailing-node, markdown-it and linkify-it leave the tree --
16 packages, none imported by this repo. That retires the reachability
note carried for linkify-it in 1.2.5.

eslint, build with the Safari 16 baseline check, i18n parity and 3514
frontend tests across 256 files all pass. npm audit --omit=dev, which
is what CI gates on, reports zero vulnerabilities.
2026-09-03 12:09:54 +02:00
maziggy e0a2398783 ci: balance the backend test shards by measured time, not test count
Backend Tests (shard 1/4) timed out after 10 minutes on e8b901f54
("Updated BACKERS"), a docs-only commit. The step was not hung: it
reached 98%, every test passing, and was killed about 12 seconds short
of finishing.

pytest-split balances by duration only when it has a durations file,
and there was none. Without one it splits by test COUNT -- 2926 / 2926
/ 2926 / 2925, exactly 25% each, which is what the comment here claimed
was good enough. Count is not time. Measured over the full suite
(11703 tests, 1045.6s):

    shard 1   2926 tests   658.2s
    shard 2   2926 tests   173.1s
    shard 3   2926 tests   113.3s
    shard 4   2925 tests   101.1s

Shard 1 was carrying 63% of the suite's runtime -- a 6.5x spread -- and
had been walking toward the cap for weeks: 475s, 404s, 445s, then 587s
on 08-30, thirteen seconds under it, and 613s here. CI printed the
reason in its own log on every run: "[pytest-split] No test durations
found."

Commit tests/.test_durations and pass --durations-path explicitly:

    shard 1   1273 tests   261.6s
    shard 2   1070 tests   261.5s
    shard 3   1522 tests   262.0s
    shard 4   7838 tests   260.6s

A 1.01x spread. The lopsided test counts are the point: shard 4 takes
thousands of fast unit tests, shard 1 keeps the slow integration ones.
The four groups still partition the suite exactly -- union is 11703
with nothing dropped or duplicated, and all four pass.

--durations-path has to be explicit because pytest-split defaults it to
$CWD/.test_durations, and the two matrices run from different
directories: the native job from backend/, the Docker job from the
image root. Left implicit, the Docker one finds nothing and silently
falls back to the count split. Node IDs match across both because
backend/tests/pytest.ini pins rootdir to backend/tests either way, so a
single file serves them both; verified the file clears .dockerignore
and lands at /app/backend/tests/.test_durations at full size.

timeout-minutes 10 -> 15 for headroom, since the file goes stale as
tests are added. Staleness degrades slowly rather than breaking --
unknown tests are treated as average.
2026-08-31 17:13:09 +02:00
maziggy e03271215b Updated BACKERS 2026-08-31 12:08:19 +02:00
maziggy 008a74c5b9 Updated CHANGELOG 2026-08-30 08:57:06 +02:00