Commit Graph
1908 Commits
Author SHA1 Message Date
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 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 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 124627d0e5 Removed unused test backend/tests/unit/test_finance_table_migration.py 2026-08-15 14:46:25 +02:00
maziggy 7d137e312b Stop auto-drying re-arming into a threshold it can never reach (#2770)
An H2D armed five 12-hour drying cycles inside four hours, one of them six
    seconds after the previous one ended, and none ran more than a couple of
    hours.

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

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

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

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

    The same bundle showed unrelated tasks failing with "database is locked",
    each inside a 30.000-second Discord connect timeout. Alarms are raised from
    inside the loop that records sensor history, at a point where the new rows
    are added but not committed; the first read in the notification path flushed
    them to satisfy itself, opening a write transaction, and the provider was
    then contacted over the network with that transaction still open. SQLite
    allows one writer and 30 seconds outlives the 15-second busy timeout, so
    every other write in that window failed. The two reads that run before a
    provider is contacted no longer flush the caller's pending work, and the
    connect timeout is 5 seconds rather than 30 -- the body keeps the full 30,
    so image uploads on a slow uplink are unaffected. SQLite only; Postgres has
    no single-writer limit.
2026-08-15 14:45:16 +02:00
maziggy 5c1c0914f4 Explain Bambu Cloud's CAPTCHA challenge instead of repeating it (#2790)
A reporter tried to connect to Bambu Cloud and got "We need you to confirm you
    are not a robot" as an error toast, with no CAPTCHA anywhere to answer and
    nothing to click. That sentence is Bambu's, not ours. Their anti-abuse layer had
    flagged the network and was answering the sign-in with HTTP 418 and a challenge
    body: {"captchaId": "...", "error": "We need you to confirm you are not a
    robot"}.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    ---

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

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

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

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

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

    Also record request-topic messages in the MQTT debug log. That topic carries
    every command a printer is given, including Bambu Studio's, and returned
    before the logging block -- so a capture could show only what the printer
    said, never what it was told.
2026-08-15 14:36:57 +02:00
maziggy a12a4a8707 fix(backup): refuse the whole LDAP family on restore, not just its password (#2656)
A settings restore could substitute the instance'"'"'s authentication source.
    auth.py reads the LDAP config live from the settings table on every
    login, and none of ldap_server_url, ldap_user_filter, ldap_auto_provision
    or ldap_default_group is credential-shaped, so the secret-key hints never
    saw them and only the four auth-policy keys were protected.

    ldap_enabled was covered by the companion-credential rule instead, and
    that rule asks the wrong question. It judges availability - "will the
    integration still work?" - and an anonymous bind works, so a payload that
    simply OMITS ldap_bind_password skips the refusal and has its toggle
    written. Omitting the credential is exactly what an attacker authoring
    the file would do: they own the directory being pointed at, so they need
    no bind credential from us.

    Left unrefused, a backup repository anyone can write to yields admin:
    point ldap_server_url at your own directory, set ldap_auto_provision and
    ldap_default_group=Administrators, and the next login on a fresh username
    is provisioned into the admin group. Overwrite-off is enough on an
    instance that never configured LDAP - there are no rows to skip.

    Refused by prefix so a key added to the LDAP schema later is refused by
    default, and matched case-insensitively because the key comes from the
    backup JSON rather than from our own writer. ldap_enabled leaves
    _COMPANION_CREDENTIALS rather than sitting there as dead code, since
    _is_protected_setting_key runs first.

    The two tests asserting an anonymous bind was a false positive are
    inverted - they encoded the hole - and the refusal reuses the existing
    settingsAuthSkipped note, which already points at Settings >
    Authentication.
2026-08-15 14:27:48 +02:00
maziggy a0d1c3674b fix(backup): keep both restore tallies equal to the number the preview showed (#2656)
Two ways the K-profile and spool categories broke the
    restored + skipped + failed == item_count invariant the settings count
    holds:

    * The spools preview counted only the spools and put the usage records
      in the detail, but _restore_spool_usage increments the same tally, so
      any backup with usage history reported a total larger than the number
      the user was shown. The preview now counts both and the detail breaks
      the total down instead of adding to it.
    * A K-profile entry that is not a dict was dropped silently on the
      connected path. _kprofile_profile_count includes it, so the offline,
      printer-missing and step-failed paths all account for it; only the one
      path that talks to a printer let it leave the tally. It now counts
      failed.
2026-08-15 14:26:14 +02:00
maziggy 43d268655f fix(backup): commit each database category so SQLite's writer is not held (#2656)
The database phase had the same shape the K-profile phase did: _find_
    archive, _find_spool, the usage dedupe and _restore_settings are all one
    SELECT per row or per key, interleaved with autoflushed INSERTs, inside a
    single open write transaction. A few thousand archives plus a full usage
    history plausibly passes the 15 s busy_timeout, and every concurrent
    writer in the app fails with "database is locked" until it finishes.

    Each category now commits before the next starts. The id maps are plain
    dicts in memory and the session is expire_on_commit=False, so the
    ordering tolerates it.

    The cost is that a later failure no longer rolls back an earlier
    category, so a tally is recorded only after its category commits and
    run_restore reports the categories already on disk instead of an empty
    result - the same correction the K-profile split needed.
2026-08-15 14:26:02 +02:00
maziggy 732b1d7034 fix(backup): gate every restore category on the permission owning its rows (#2656)
settings was gated on settings:update because a restore rewrites rows
    PUT /api/v1/settings/ owns. The same argument applies to the other three
    categories, and gating one but not the rest is the only state that is
    not defensible: a role holding Backup alone could still write spools,
    archives and K-profiles through a restore that it cannot write through
    the endpoints that own them.

    Each category now also requires that endpoint's write permission -
    inventory:update, archives:update_all and kprofiles:update. archives
    takes update_all rather than create because a restore writes rows owned
    by other users, which is exactly what update_all means.

    All missing permissions are reported in one refusal: a restore is a
    multi-select, so naming them one at a time turns picking four categories
    into four round trips.
2026-08-15 14:25:46 +02:00
maziggy 2ef43943d3 docs(backup): say what the secret-key hints actually refuse (#2656)
The comment called the hint list belt-and-braces over keys the collector
    already refuses to write. It is not: _collect_settings filters exactly
    bambu_cloud_token and auth_secret_key, so a current backup really does
    carry mqtt_password, ldap_bind_password, ha_token and prometheus_token,
    and the hints are the only thing that refuses them. The companion-
    credential rule sits downstream of that, so reading the list as redundant
    and shortening it would write a stale credential and make that rule inert
    at the same time.

    Comment and test docstring only - no behaviour change.
2026-08-15 14:25:31 +02:00
maziggy ca6b9f8fe0 fix(backup): resolve a restored archive's owner by username, not by id (#2656)
created_by_id is only meaningful on the instance that wrote it. Restoring
    onto a rebuilt instance - this feature's main use case - renumbers the
    users table, so a live id can land on a different person and hand one
    user's print history to another under archives:read_own. The id path
    cannot even detect that: archivesOwnerCleared fires only for an id that
    is absent, so a valid-but-wrong id produced no note at all.

    The collector now records created_by_username alongside the id, and the
    restore prefers it. username is unique on users, so a match is the same
    person; the one case it cannot resolve - a user renamed since the backup
    - falls through to ownerless with a note rather than guessing from the
    id. The id stays as the fallback for backups taken before this change.
2026-08-15 14:25:16 +02:00
maziggy 09a7413efc implemented pr feedback #2 2026-08-15 14:24:45 +02:00
maziggy 12c6749abe Merge remote-tracking branch 'upstream/dev' into feature/billing 2026-08-15 14:24:32 +02:00
maziggy b1610f70ae Security hardening (maziggy/bambuddy-security #9) 2026-08-15 14:24:04 +02:00
maziggy 5bee8df5cd Broadcast AMS slot changes that keep the same material
Configuring a slot from the printer card left the card showing the old
    filament until a reload or the 30s fallback poll. The command reached the
    printer and the printer applied it; the update just never got broadcast.

    on_printer_status_change deduplicates WebSocket pushes against a status_key
    whose AMS part carried id, tray_type and state. Configure Slot writes none of
    those -- it writes tray_info_idx, tray_color, tray_sub_brands and cali_idx. So
    PLA to another brand or colour of PLA produced an identical key and was
    dropped, while PLA to PETG came through. Reset always worked because it clears
    tray_type.

    Those four fields only move when someone configures a slot or swaps a spool,
    so this costs no broadcasts mid-print. remain stays out of the key for the
    opposite reason.
2026-08-15 14:23:47 +02:00
maziggy f35c184b52 Show Home Assistant sensors on the printer card (#1148, #448)
Binds binary_sensor and reading-carrying sensor entities to a printer and
    renders their state on its card, worded by Home Assistant's device_class.
    Optional per-sensor alert condition drives a notification on the transition
    into the alert state and an opt-in interlock that holds queued prints while
    alerting -- a hold with a readable waiting_reason, never a failure, and only
    ever on a sensor that was read successfully.

    Sensors get their own table rather than a wider entity pattern on SmartPlug:
    get_smart_plug_by_printer would otherwise hand the card's power button a door
    contact to switch.

    The hold is passed to the model matcher directly rather than merged into
    busy_printers: _check_auto_drying reads that set as "is currently printing"
    and would put an idle-but-held printer down the mid-print drying path.

    The notification_providers migration spells its default FALSE, not 0 --
    Postgres rejects an integer default for a boolean and _safe_execute swallows
    the error.
2026-08-15 14:23:00 +02:00
maziggy 372ab3b788 Route external spools to a nozzle when the printer has no AMS (#2771)
Five X2Ds with no AMS, each printing from its external spool holder,
    took a job sent to a named printer and refused the same job sent to
    "Any X2D": the file uploaded, the firmware answered 0700_8012 "Failed
    to get AMS mapping table", and the item failed after three attempts.

    A named-printer job carries a mapping the frontend resolved at queue
    time, so the scheduler's matcher never runs. A model-based job has no
    printer until dispatch, so the matcher does run -- and could not see an
    external spool on a dual-nozzle printer. _build_loaded_filaments derived
    dual-nozzle status from ams_extruder_map, which is built from AMS info
    bits, so a printer with zero AMS units reported an empty map; every
    external spool got extruder_id=None, and the nozzle-aware hard filter in
    _match_filaments_to_slots discarded it because None equals neither 0 nor
    1. The mapping came back all -1, was cleared to None, and the print
    command went out as use_ams:true with no ams_mapping and no
    ams_mapping2 at all.

    This is the backend half of #1257, which fixed the same logic in
    useFilamentMapping.ts and left this copy behind. Mirror its inference:
    a populated nozzles[1].nozzle_diameter, a non-empty ams_extruder_map, or
    more than one vt_tray entry. Replaying the reporter's own push-status
    now yields extruder 1 for Ext-L and 0 for Ext-R, and a nozzle-1
    requirement resolves to [254] -- what their working named-printer
    dispatch sent. Single-nozzle printers keep extruder_id=None; nozzles
    always has two entries, so its length alone must not be the signal.

    Also stop dispatching a job the firmware is certain to reject. When the
    matcher ran, matched nothing, and the printer has no AMS, fail the item
    with the filament and nozzle it wants instead of spending an upload and
    two retries on it -- that path already ended in a failed item, just an
    opaque one. With an AMS attached the firmware error still stands, since
    there the user can load a spool and press Resume. Fail-safe like the
    nozzle-diameter guard (#1899): every branch short of a positive finding
    returns None and dispatches as before.

    _apply_filament_overrides is extracted from _compute_ams_mapping_for_printer
    so the message names the filament the matcher looked for rather than the one
    the 3MF was sliced with.
2026-08-15 14:22:24 +02:00
maziggy ae802e74ca Say why a drying cycle ended when the firmware cuts it short (#2770)
An H2D started a twelve-hour PETG dry at 65 degC and the AMS gave up on it
    twenty minutes in, with 700 of the 720 minutes still on the clock. It cooled,
    humidity climbed back over the threshold, auto-drying started another
    twelve-hour cycle, and that one went the same way; the reporter's AMS
    temperature history shows the loop running all morning.

    The log had one line for it: "AMS 0 drying complete", which is exactly what it
    says for a dry that ran its full twelve hours. Nothing in a support bundle told
    the two apart, and the one number that does -- the time still remaining -- was
    written into that line as the previous value, where it reads like a duration
    rather than a shortfall. The reporter took 700 for seconds and concluded the
    cycle had lasted twelve minutes.

    Bambuddy did not stop that cycle; every stop it sends is logged with the full
    outgoing command and there was none. So ending it was the printer's decision,
    and the account of why lives in three things already received and parsed and
    never written down: the drying phase and sub-phase from the AMS info hex, the
    per-unit dry_sf_reason constraint codes, and the live HMS errors.

    A cycle that ends with most of its countdown left now logs all three alongside
    how much of the requested duration ran. One that reaches its duration keeps the
    single line it has always had. A stop Bambuddy sent is named as ours -- it is
    short of its duration too, and on the telemetry alone is indistinguishable from
    the firmware abandoning the cycle, so without tracking it the print-takes-priority
    stop and the Stop button would both have been blamed on the printer.

    Diagnostics only. Nothing about when drying starts or stops has changed, and the
    restart loop is not addressed: what the firmware objects to has to be established
    before Bambuddy can sensibly decide how long to wait before trying again.
2026-08-15 14:22:04 +02:00
maziggy da1f655c3d Cover nested data structures in the HA notify pass-through (#1441)
The three tests around it use flat scalars, which is also all the field's
    placeholder and the wiki showed, so nothing recorded that the value is
    forwarded verbatim rather than treated as a key/value list. A reporter asked
    whether action buttons work; they always have, and now that is pinned.

    The changelog entry said "nested options work" and left it there. It now names
    actions and the two things that decide whether the buttons do anything - the
    mobile_app_notification_action automation, and iOS needing a registered
    category - since neither is set from Bambuddy and both are what a reader would
    otherwise have to discover the way the reporter did.
2026-08-15 14:20:40 +02:00
maziggy d8d637cbfe Survive a directory that defines no posixGroup class (#2769)
Every LDAP user on an lldap directory was rejected with "Incorrect
    username or password", on an install where Test Connection passed and
    where the same bind DN, filter and group membership all checked out
    under ldapsearch. The directory never saw the request.

    _extract_user_info searches for POSIX groups alongside the memberOf
    ones, and both of those filters name the posixGroup object class. ldap3
    fetches the schema at connect time (get_info=ALL) and validates class
    names in a filter against it while building the request, raising
    LDAPObjectClassError before anything is sent. lldap marks every account
    it creates as posixAccount -- which is what makes us look for POSIX
    groups at all -- but defines no group class beyond groupOfNames. The
    exception escaped authenticate_ldap_user, and the login route reports
    any LDAP failure as bad credentials.

    A directory with no posixGroup class has no posixGroup entries, which is
    exactly the answer those searches would have returned. Catch it, log it
    once, and carry on with the memberOf groups collected above. Both
    searches sit inside the one try: they name the same class, so once one
    is rejected the other cannot succeed, and attempting it would only
    produce a second identical exception to swallow.

    Not a regression from 848f55810. The memberUid filter has named the
    class since b6599dd41 and runs for every user whether or not they have a
    gidNumber, so a directory of this shape has never been able to log in;
    the primary-group lookup only added a second trigger. Test Connection
    was unaffected throughout because (objectClass=*) is a presence filter
    and never reaches the value validator.

    The mock connection gained a hook that raises on a filter substring,
    standing in for that client-side validation. Reset in the fixture and
    default None, so existing tests are unchanged.
2026-08-15 14:20:07 +02:00
maziggy 868cfa2d1e Read the printer's own slot mapping when Spoolman has none (#2768)
A sliced file numbers its filaments 1..4; which AMS tray each came from is
    a separate decision made when the job is sent. store_print_data learns it
    from one of two sources, both of which require the print command to pass
    through us: the mapping Bambuddy chose itself, or the one it intercepted
    on the printer's local request topic. A job dispatched from Bambu Studio
    while the printer is cloud-bound satisfies neither -- the command travels
    through Bambu's broker and never reaches the topic we subscribe to.

    slot_to_tray is then NULL and _resolve_global_tray_id guesses by position:
    filament 1 from the first loaded tray, filament 2 from the second. The
    reporter's X1C was loaded in the order 2, 4, 1, AMS-HT, so all four slots
    were charged to the wrong spool. Their log carries the printer's own
    answer, mapping=[1, 3, 0, 32768], sitting unread.

    usage_tracker has consulted that field since it started resolving mappings
    at completion, along with a colour match against the loaded trays for the
    models that never publish it (A1, A1 Mini, P1S, P2S). Only the Spoolman
    writer, which resolves at print start, never learned to -- and main.py
    gates usage_tracker behind Spoolman being off, so enabling Spoolman is
    what costs you the better resolver.

    _resolve_slot_to_tray_fallback gives it both, at completion rather than at
    print start: a printer keeps publishing the last job's mapping while it
    sits idle, so reading it early would risk stamping the previous print's
    mapping onto this one. A mapping we or the slicer actually recorded is
    never second-guessed.

    Applied in _report_partial_usage too. Cancelled and failed prints feed the
    same slot_to_tray to the same resolver and mis-charged just as readily.

    The resolved mapping and its source are now logged at print start and at
    completion. "source: none" at start is the signal that completion will
    have to fall back, and it was the one line that would have turned this
    report into a five-minute triage.

    Not addressed: editing the mapping after the fact, which the reporter also
    asked for. ArchiveUpdate exposes neither filament field and there is no way
    to re-run an attribution, so that is a feature rather than a fix.
2026-08-15 14:19:45 +02:00
maziggy 4335ee3814 Stop the drying badge inventing a temperature on a uniform AMS (#2759)
The follow-up to the same report: a second AMS 2 Pro, no aux power,
    loaded entirely with PLA and drying at the 45C the reporter picked,
    showed 45C and then switched to 55C.

    Bambu never echoes back a cycle's filament or temperature, so both come
    from the target cached when the command went out, and the fallback for
    a missing cache reads the loaded trays. The first pass narrowed that
    fallback to units whose spools agree on a filament, which fixed the
    mixed-unit case in the original report but left the uniform case
    answering with the spools' RFID-recommended drying_temp -- 55C here.
    Agreement across slots is evidence of what is being dried, because the
    dryer heats all of them. It is no evidence of the temperature, which is
    picked freely in the popover, so the recommendation was never more than
    a guess wearing the same confident "PLA @ 55C" as a known target.

    uniform_tray_drying_hint therefore becomes uniform_tray_filament_hint
    and returns the filament alone. The badge names a temperature only when
    we sent it, and otherwise shows the filament and the countdown.

    Both status builders also stopped filling the two fields independently.
    Entering the fallback when either was missing let a cached filament pair
    with a guessed temperature and render as though both were known; the
    temperature now simply has no fallback to reach.

    The badge required both fields before rendering anything, so dropping
    the temperature would have blanked it rather than shortening it -- the
    frontend now renders each on its own terms. No new translation key: the
    filament type is a passthrough.

    This changes what is shown when the cached target is missing, not why
    it goes missing. If the reporter was on the fixed build, the falling-
    edge gate is still letting a zero through on an unpowered unit, which
    needs a log covering the start of the cycle.
2026-08-15 14:19:15 +02:00
maziggy 0827ebc5b7 fix(backup): let the nozzle_id default apply, and prefer the live one (#2656)
`set_kprofiles_batch` defaults the field with
    `p.get("nozzle_id", f"HS00-{diameter}")`, and a `dict.get` default only
    fires when the key is *absent*. The restore always set the key —
    `"nozzle_id": p.get("nozzle_id")` — so a backup that carries no nozzle_id
    published `nozzle_id: null` to the printer instead. Printers that omit the
    field (KProfilesView's own #1748 comments) are exactly why the default is
    there, and it was unreachable from this path.

    Set only when known, on the same precedence the `setting_id` line beside it
    already uses: the live profile first, then the backup, then absent. Live
    first is the point rather than a bonus — `nozzle_id` encodes the fitted
    nozzle's type as well as its diameter (`HS00-` hardened vs `SS00-`
    stainless), so a nozzle swapped since the backup makes the stored value
    stale, and the write lands on the nozzle fitted now.

    Read with `getattr`, matching the defensive read of `extruder_id` in
    `_match_kprofile`: not every live profile carries every field, and
    `test_a_live_index_that_reports_no_extruder_still_matches` is the standing
    control for that.

    Four tests, two of which fail without the fix. The `_live` double also
    gained `nozzle_id` — it is a non-default field on the real `KProfile`
    dataclass, so omitting it let the double license code the real object
    would have accepted.
2026-08-15 14:17:36 +02:00
maziggy 03b2ce5c16 fix(backup): say when a restored archive lands without an owner (#2656)
`created_by_id` is not attribution, it is the column the access check runs
    on: `_ensure_archive_visible` fails closed on NULL, so an ownerless archive
    is a 404 for every caller without `archives:read_all` and never appears in
    the ownership-scoped list queries.

    On the overwrite path an absent key correctly leaves the local owner alone
    — that rule is deliberate and unchanged. On the insert path there is no
    local row to fall back on, so the archive lands ownerless, and nothing said
    so. The restore reported N archives restored while the user who asked for
    them saw none. Two ways in, both silent: a commit taken before the
    collector recorded the column (every pre-#2656 backup), and an archive that
    genuinely had no owner on the source instance.

    Adds `archivesOwnerUnknown`, emitted on insert only, and suppressed when
    the stale-id branch has already spoken for that row so one cause does not
    produce two notes. Wording mirrors `archivesOwnerCleared` because the
    consequence and the remedy are the same; the cause is not, so it is a
    separate code rather than a reuse.

    Five tests, plus the existing `test_a_backup_without_the_key_still_restores`
    renamed and tightened — it asserted the silence this fixes. 13 locales back
    in parity at 5772 leaves. No modal change: notes render through
    `translateCoded`, which resolves by code.
2026-08-15 14:17:21 +02:00
maziggy a83645c859 fix(backup): dedupe spools and usage history on a comparison that can match (#2656)
Both `created_at` columns the restore dedupes on are
    `server_default=func.now()`. SQLite fills those from `CURRENT_TIMESTAMP`,
    which has second precision and stores `'2026-08-02 11:28:41'`, while
    SQLAlchemy binds a Python datetime as `'2026-08-02 11:28:41.000000'`.
    SQLite compares the two as strings, so `Model.created_at == created_at`
    never matched a row the application itself created — not even when handed
    that row's own value straight back out of the ORM.

    Every dedupe keyed on it therefore missed, on the ordinary case rather
    than an edge one:

    * `_find_spool`'s composite fallback duplicated every tag-less spool on
      each restore, and `overwrite_existing=True` never reached the original;
    * the usage-history dedupe re-inserted the user's entire consumption
      history on each restore.

    Rows the restore itself had inserted did match, because those carry an
    explicit bind in the same microsecond format — which is why the existing
    repeat-restore tests passed throughout.

    Fixed by filtering the candidates in SQL and comparing `created_at` in
    Python, which sidesteps the bind format and behaves identically on
    PostgreSQL, where the column keeps microseconds and the SQL comparison
    happened to work. `_parse_dt` now also normalises an offset-bearing value
    to naive UTC, matching what the naive columns actually hold; the collector
    never writes one, so that guards hand-edited and foreign backups.

    Seven tests, six of which fail without the fix. They seed the "existing"
    row the way the application does — no explicit `created_at` — which is
    what the existing coverage was missing.
2026-08-15 14:17:07 +02:00
maziggy accafafa64 fix(backup): report the rows a failed K-profile step already committed (#2656)
`_apply` commits the database categories before the K-profile phase, and the
    comment there is right about why: `get_kprofiles` is 3 x 5 s per printer per
    nozzle and SQLite's `busy_timeout` is 15 s, so holding the writer across the
    MQTT phase would fail every concurrent writer in the app.

    But `run_restore`'s handler returns `{"success": False, ..., "results": {}}`
    for anything raised after that point, and the per-call guards inside
    `_restore_kprofiles` do not cover the whole phase. Two consequences, and the
    second is worse:

    * The user is told the restore failed and handed an empty `results` while the
      archive, spool and settings rows are durable on disk. The honest-reporting
      theme this whole feature is built on inverted on exactly the path where it
      matters most.
    * `_reconfigure_mqtt_relay` sits inside the same `try`, downstream of the
      raise. A restore that rewrote the mqtt_* rows left the relay pointed at the
      pre-restore broker until something else reconfigured it.

    `_apply` now contains the K-profile phase: fold the error into that category's
    tally as `failed` plus a `kprofilesStepFailed` note, and let the results it has
    already committed be returned and reported. Every profile the payload carried
    and the phase did not account for is counted failed — silence would have been
    the same lie in a smaller font. `_reconfigure_mqtt_relay` is reached again
    because `_apply` returns normally. The rollback in the handler discards only
    the phase's own read transaction, so a database error cannot leave the session
    in a state that turns the caller's commit into the very report this prevents.

    `kprofilesSendFailed` was the obvious note to reuse and is the wrong one: it
    names a nozzle, a printer and a serial that a phase-level failure does not
    have, and "failed to send" is untrue of a step that never got as far as
    sending. One new leaf x 13 locales instead.

    Belt-and-braces on the trigger that found this:
    `sum(len(c.get("profiles") or []) ...)` raises TypeError on a hand-edited or
    truncated backup whose `profiles` is not a list, and it runs before the guards.
    Counting defensively makes that a skipped category rather than an exception
    thrown over committed rows.

    Control kept explicit: a failure *before* the commit still rolls back, still
    reports nothing restored, and still does not touch the relay.

    Tests: +5 (280 -> 285 across the three restore files, 328 -> 337 across
    `-k github`). Fail-pre-fix 4 — 3 for the containment, 1 for the defensive
    count, checked separately. i18n parity 13 locales at 5771 leaves.

    Bundle rebuilt for the new leaf: index-CHCEEMgx.js -> index-DhOfNgMz.js. CSS
    hash unchanged.
2026-08-15 14:16:44 +02:00
maziggy 1f07f39481 fix(backup): stop Gitea's tree pager failing open on a missing total_count (#2656)
`if not isinstance(total, int) or seen >= total or not entries: return blobs, ""`
    — the first arm short-circuited the page loop into a **success** holding page 1
    only. Gitea clamps `per_page` to `MAX_RESPONSE_ITEMS` (default 50), so that is
    50 entries of an arbitrarily large tree returned as a complete listing.

    The restore then reports genuinely-present categories as "Not present in this
    backup commit". That silent skip is the exact failure this override exists to
    prevent, and the same class as E7 and G2 — G2 fixed the arithmetic here and
    left the shape. GitHub and GitLab both hard-fail in the equivalent spot; only
    Gitea guessed, and it guessed in the one direction that loses data quietly.
    Whether Gitea always sends `total_count` on this route is beside the point: the
    code was defending against a response shape it did not trust, and then trusting
    it.

    Now a missing or non-int `total_count` means "page until a short or empty
    page". A page shorter than the first one is the last one, floored at Gitea's
    default clamp so a genuinely small tree still costs exactly one request — the
    reason G2 rejected paging-until-short in the `total_count`-present case, which
    is unchanged and still stops on the count. The existing `page <= 50` ceiling
    gives the correct hard failure for a tree that really is over cap, so this
    cannot truncate.

    Residual, and deliberately not widened into a `return None` on the first
    ambiguous response — that would break single-page trees, the common case: an
    instance whose `MAX_RESPONSE_ITEMS` is set *below* 50 *and* which omits
    `total_count` would still stop at page 1. Both halves have to be true.

    Tests: +6 (paged to the end with no count, on both Gitea and Forgejo; a short
    page ends it; a non-int count is treated as no count; the page ceiling still
    fails). Fail-pre-fix 5, control that passes either way 1 (a small tree is one
    request). These don't match `-k github`, so 274 -> 280 across the three restore
    files but `-k github` is unmoved.
2026-08-15 14:16:27 +02:00
maziggy 214df517ac fix(backup): match a K-profile on its own extruder, not just its filament (#2656)
`_match_kprofile` scoped candidates by `filament_id` alone, and
    `_current_kprofile_index` reads the live index per nozzle *diameter* — so on a
    dual-nozzle printer both extruders' profiles come back in one list.

    On an H2D with the same filament calibrated on both extruders, the `setting_id`
    arm then matched whichever profile the printer happened to list first. A
    backed-up extruder-0 entry took extruder 1's slot and went into the batch as
    `{extruder_id: 0, cali_idx: <extruder-1 slot>}`, writing one extruder's
    calibration over the other's and counting it restored. With an entry per
    extruder — the ordinary case, since the same preset on both nozzles is what a
    dual-nozzle printer is for — the two swapped slots and clobbered each other.

    The `name` arm and the single-candidate fallback were equally unscoped, so this
    was never only about the ambiguous case.

    The data was already in hand and already being read: the backup entry carries
    `extruder_id` (`:1546` copies it straight into the outgoing dict) and
    `KProfile` has carried `extruder_id: int` on the live side all along. Scope
    `candidates` by it the same way `filament_id` already scopes them, and the
    single-candidate fallback narrows with them — ambiguity is judged within one
    extruder now.

    Conditional on both sides saying which extruder they mean. A pre-#2656 backup
    has no `extruder_id`, and a live index that reports none must not turn every
    entry into an add — that would be a far worse regression than the bug. Two
    controls cover each direction of that.

    `claimed` (G3) is untouched: it is per nozzle-loop and this only narrows the
    candidate set feeding it.

    Tests: +4 across the three restore files (270 -> 274). Fail-pre-fix 2 (each
    extruder keeps its own slot; the other extruder's profile is not a stand-in),
    controls that pass either way 2.
2026-08-15 14:16:12 +02:00
maziggy 4599b6f4b4 fix(backup): read the printer's verdict before counting a K-profile restored (#2656)
`18938a10` on `dev` changed `set_kprofiles_batch` from returning a `bool` to
    returning the sequence_id it published the command under, and moved the
    verdict to a separate `await client.await_cali_ack(seq)` returning
    `(ok, detail)`. Every caller in `api/routes/kprofiles.py` was updated with it.
    `_restore_kprofiles` was not — it still did `sent = client.set_kprofiles_batch(...)`
    and branched on `if sent:`.

    A sequence_id string is truthy, so that compiled, passed, and silently made
    the restore the one path left in the codebase that reports a refused
    K-profile write as saved — exactly the defect `18938a10` closed everywhere
    else.

    Keep the sequence_id, await the ack per batch, and route an explicit refusal
    into `tally.failed` with a new `kprofilesRefused` note carrying the printer's
    own `reason`. Reusing `kprofilesSendFailed` would have been wrong: the
    command was sent, and the printer answered.

    Silence still counts restored. That is `await_cali_ack`'s own contract and
    the maintainer's rule — no answer is not evidence of refusal, and firmware
    predating the ack never answers. An exception reading the ack degrades the
    same way rather than inventing a failure out of a write that most likely
    landed.

    `kprofilesAckUnreliable` is reworded to match: a refusal is now believed, so
    the caveat narrows to what is genuinely left uncertain. The ack is only worth
    reading at all because `18938a10` also changed the payload's `tray_id` from
    `-1` to `0` — single-nozzle firmware answered `result: "fail"` to `-1` on
    writes that demonstrably applied. This restore builds no `tray_id` of its
    own, so it inherits that fix for free.

    Tests: 4 regression (the ack is awaited for the returned sequence_id; a
    refused batch counts failed and surfaces the printer's reason; one refused
    nozzle does not condemn the other; the reworded caveat) + 3 controls (a
    silent printer still counts restored; an unreadable ack does not fail the
    batch; `None` keeps the existing send-failed path and awaits nothing). All
    four confirmed failing against the pre-fix service.
2026-08-15 14:15:45 +02:00
maziggy 29311cab2b fix(backup): refuse prometheus_enabled when the backup has no token either (#2656)
The companion-credential rule has five conditions, and the second one -- "the
    backup itself carried a usable credential" -- was applied to all five pairs. It
    should not be. It is what stops the rule over-refusing an anonymous MQTT broker
    or an anonymous LDAP bind, both of which are working configs: there, an empty
    credential in the backup means the restore is not producing anything weaker
    than what was backed up.

    For prometheus_enabled it does not transfer. An empty prometheus_token removes
    /api/v1/metrics' only gate, so the exposure is a property of the toggle, not of
    a downgrade relative to the backup -- and prometheus_token is optional, so a
    backup taken on an instance that enabled Prometheus without ever setting one
    carries the toggle and no usable token. That payload skipped the refusal
    entirely: not a candidate, so the local-state pass never ran, and the blocklist
    quietly dropped the token key. On a token-less target the result was
    prometheus_enabled=true, no token row anywhere, and a full unauthenticated
    metrics dump -- the same hole the rule was written to close, reached from the
    likelier of the two directions.

    So condition 2 is now per-pair: an exposure class (prometheus) that skips it and
    is judged on local state alone, and an availability class (mqtt, ldap, ha,
    virtual_printer) that keeps it. Nothing else changes -- the local-state pass
    already stands down when the instance has its own credential, when HA_TOKEN is
    in the environment, and when the toggle is already on locally, so "the exposure
    pre-dates this restore" still holds and refusals still get no tally increment.

    One wording consequence: an exposure toggle can now be refused on a payload with
    no credential-like key in it at all, where the shared caveat would have read "0
    credential-like key(s) will be skipped". That case gets its own preview detail
    code, settingsCompanionOnlyWillSkip, in all 13 locales.

    Tests: 6 that fail pre-fix -- the token key absent and blank at the unit level,
    the new preview wording, and the integration test through the real endpoint for
    both payloads (200 with a full metrics body before this, 404 after). Plus 3
    controls, because over-refusal is still the real risk: the exposure route must
    still stand down for a local token and for an already-on toggle, and the
    availability class must still let a credential-less mqtt/ldap/virtual_printer
    toggle through. The anonymous-broker and anonymous-bind controls are unchanged
    and still pass.
2026-08-15 14:15:13 +02:00
maziggy 10c9e53e84 docs(backup): give the real reason cloud profiles are not restorable (#2656)
Both docstrings said the collector never writes `cloud_profiles/*.json`. That
    was true when they were written and stopped being true at `455a9e4b` (#2717,
    "collect cloud profiles from every connected account"), which is this
    branch's rebase base — so the PR was shipping a stated reason its own base
    had invalidated.

    The real reason is the one the PR body now gives: restoring a preset means
    writing to a Bambu or Orca Cloud account, which is a different operation from
    every other category here. Those land in the local database, or on a printer
    the instance already owns.

    Checked that nothing in the restore path is confused by the new files — the
    category globs don't reach `cloud_profiles/`, and it stays out of
    `RestoreCategory`.
2026-08-15 14:14:58 +02:00
maziggy 06038ecb15 fix(backup): require settings:update to restore the settings category (#2656)
The restore endpoint was gated on `github:restore` alone, and the settings
    category rewrites arbitrary non-auth `Settings` rows. Backup and Settings are
    separate permission groups, so a role holding only Backup could change
    settings it cannot change through `PUT /api/v1/settings/`, the endpoint that
    owns them.

    The inconsistency is ours rather than an inference: this module already makes
    exactly this argument — it is why the four protected auth keys are refused
    outright — and `library.py` sets the precedent of elevating a route to
    `settings:update` for the same reason.

    Gated per-category rather than by demoting `github:restore` wholesale, so it
    stays narrow and doesn't presume the answer for `spools:*`, `archives:*` and
    `kprofiles`. That broader permission-model question goes to the maintainer in
    the PR reply.

    `current_user is None` only means auth is disabled — `github:restore` is in
    `_APIKEY_DENIED_PERMISSIONS`, so an API key never reaches the route body.

    No frontend change: `request()` puts the 403's `detail` on the Error, and the
    modal already renders `restoreMutation.error.message` in its red block, so
    the user sees the missing permission named.

    Tests: 1 regression (a Backup-only role gets 403 and `run_restore` is never
    awaited) + 3 controls (the same role still restores the other three
    categories; a role holding both permissions still restores settings; auth
    disabled is unaffected). The regression confirmed failing against the pre-fix
    route. `_create_config` gained an optional token so it works under auth.
2026-08-15 14:14:43 +02:00