Commit Graph
4125 Commits
Author SHA1 Message Date
maziggy 93585beb7b Let a clear spool stay clear on the way to Spoolman (issue #2912) (#2924) 2026-08-26 10:19:50 +02:00
maziggy 8f182dc181 Read a NULL notification flag as off instead of dropping every provider (issue #2827)
Adding on_stock_reorder_alert and on_stock_break_alert to the provider
    schema made them required on the way out as well as in: the response model
    inherits the write model. Every on_* column on notification_providers is
    nullable with no server default, and where the table was created from
    Base.metadata before run_migrations, the ALTER ... DEFAULT false that
    introduced those columns was swallowed as a duplicate and never backfilled
    existing rows. Those NULLs were harmless until the flags were read, at
    which point the row failed validation -- and a list is validated as a
    whole, so one row took every provider with it. The route returned 500 and
    the UI rendered an empty list, so configured providers looked deleted.

    Backfill them to off, which is what the sender already assumed: it selects
    providers with IS TRUE, so a NULL flag never sent anything. A NULL flag now
    also reads as off rather than failing the response, across all of them, so
    the next flag added to this schema cannot repeat it. Writes are unchanged.
2026-08-26 10:19:17 +02:00
maziggy 920c6d1549 Read a print's destination from the report topic, not just the request one (issue #1820)
current_project_url was assigned in exactly one place, _handle_request_message,
    and _on_message calls that only for the request topic. A print started from the
    printer's own screen publishes nothing there, so the field stayed None for the
    one case the storage verdict exists for: the file is already in the printer's
    model library under /userdata/model/history/, which port 990 does not serve.

    The verdict then fell through to the sdcard flag, and @ojimpo's H2S reports that
    flag true -- its "card" is the internal eMMC -- so every such print ran the full
    sweep before giving up. He measured one: 16 filename-and-directory attempts over
    22 FTPS connections, 18 of them refused, 6.4 seconds, then a fallback archive
    holding a name and nothing else.

    The printer does announce where the file lives, as an unsolicited project_file
    *response* on the report topic about two seconds before gcode_state reaches
    PREPARE. _process_message now reads the url off it, gated on result SUCCESS and
    a non-empty value so a refused dispatch cannot name a file that was never
    written. Reading it there rather than only at the request topic also covers an
    install neither of us had in view: some brokers refuse the request-topic
    subscription, and on those no print of any kind had ever populated the field.

    The new branch captures state and nothing else. The "External project_file
    payload" diagnostic stays with the request-topic handler: our own dispatch is
    echoed on both topics, the request-topic echo lands first and clears
    _own_project_file_key, so reusing the diagnostic here would have logged every
    Bambuddy-started print as somebody else's. A test pins that.

    What the print names is now what gets tried -- the five directories a copy could
    be in, rather than the ~110 connections that cannot succeed. The probe is still
    worth running: an H2S keeps recently used jobs under /cache and archives them in
    full while they last, which is why the reporter's two prints on the same day
    behaved differently. Slicer-sent prints are unchanged.

    The banner no longer describes a step that never happened. With no reason
    recorded, a blank archive fell back to the original wording -- "Store sent files
    on external storage" is off in your slicer -- which on that printer is on, and
    which the internal-storage wording from #2780 already explains would not help on
    an H2. The archive that most needed that explanation was the only one that could
    not be given it.

    So file:///userdata/ now earns its own reason, internal_history, separate from
    the brtc://emmc dispatch case. A dispatch chose internal storage and can be
    aimed elsewhere; a print of a file that was already there had no dispatch at
    all, and telling that operator to pick External in Send names a dialog they
    never opened. The banner and the connection diagnostic both read the verdict's
    reason rather than a fixed one, so the two surfaces cannot give the same printer
    different advice. Thirteen locales, and a wiki section the banner links to.

    -----

    Read the K-profile selection when the mutation runs, not when it is captured

    Configure Slot sends cali_idx from selectedKProfile, and the mutation read it
    through its own closure. React Query hands a mutation its options from an
    effect, so a click landing between a commit and that effect flushing runs the
    previous render's mutationFn -- one that captured the selection as it was before
    the K-profile query resolved. The payload then carries cali_idx -1 and the
    printer binds the default 0.020 instead of the calibrated K, while the dialog
    shows the right profile selected throughout.

    It surfaced as an intermittent failure of the per-nozzle K-profile test, about
    one full-suite run in six. Reproducing it with staggered query resolution showed
    the divergence directly: the select element held the correct profile immediately
    before and after the click, and the payload still carried -1. That test's slot is
    the most exposed case in the file -- a right-hotend slot carrying the left
    hotend's index, where the "keep showing the active profile" safety net cannot
    repair an empty recompute.

    The selection now goes through a ref written during render, so the mutation
    resolves it at execute time. An effect would have inherited the same flush
    ordering this exists to escape. The K value and the profile's ids travel in the
    same payload and had the same exposure, so they move with it.

    Measured over a staggered-resolution grid: 2 failures in 15 runs before, 0 in 12
    after. api.getSlicerPrinterModels was also missing from the test file's mock, so
    that query ran with no query function and rejected in all 37 tests -- mocked now,
    though on its own it changed nothing, which is how the ref was confirmed as the
    fix rather than assumed.
2026-08-26 10:18:12 +02:00
maziggy 1b2ebe0577 Fill in a fallback archive when the 3MF finally arrives (issue #2957)
A failed TLS handshake pauses a printer's file service for five minutes, and
    the archive flow checks that pause at the top of its path loop and gives up
    before opening a connection. A print that starts inside one gets an empty
    fallback archive 13 milliseconds later, having never touched the network.

    Four minutes on, the pause clears and the cover endpoint downloads the same
    file, parses it, takes a thumbnail out of it, and publishes it to the shared
    3MF cache under the exact key the archive flow looks up. Nothing ever looks:
    all three readers of that cache run before or during the print-start handler
    that already gave up, and print completion drops the cache as its first act,
    deleting the file. No path existed by which a fallback archive could become a
    real one.

    Offer a later 3MF to the running print's archive, filling the existing row
    rather than adding a second -- the row id carries the energy reading, the
    timelapse session and the start notification. That covers the reported case at
    no network cost, since opening the printer card already downloads the file.

    Schedule a bounded retry when the pause is what caused the fallback, spending
    the cache first and the printer only if that misses. Not scheduled for the
    other cause: a print kept on internal eMMC has no FTPS copy to come back for,
    and retrying it is the sweep removed in #2780. The two reasons are now recorded
    separately instead of both landing as "no 3MF".

    Recovery refuses anything that is not a readable 3MF -- a truncated download
    would replace an honest empty archive with wrong metadata -- and leaves an
    archive alone once it has a real file.

    Three things the recovery path has to get right, each with a test:

    Reduce every retry candidate to a bare name. MQTT hands `filename` over as
    "/data/Metadata/plate_1.gcode" on some firmware, and joining that onto the temp
    directory yields the absolute path itself, so the retry is fed the flow's own
    sanitised candidate list and strips the path again on its own account.

    Serialise recovery per printer. The cover endpoint's single-flight coalesces by
    view, so two views race each other, and the retry task and print completion can
    land on top of either -- each reads file_path == "" and runs a full copy,
    leaving the row on one timestamped directory and the rest orphaned.

    Keep looking for photos in the pre-recovery directory. `archive_dir` derives
    from `file_path`, so filling the row moves the archive's directory, and a photo
    uploaded to the empty card while the print ran stays where it was put.
2026-08-26 10:17:44 +02:00
maziggy f6e8767aeb Do not report a printer as Safe when nothing is checking it (issue #2952)
The printer card's AI badge collapsed every class that was not Warning or
    Failure into green Safe, and the service reported `safe` whenever it had no
    verdict. The state entry is created when a monitored print is first seen --
    before the first snapshot, let alone the first inference -- so a rejected ML
    API token, an unreachable ML API, a failed capture and an unset External URL
    all rendered as a healthy watched print: green Safe at score 0.000.

    For a safety feature that is the worst failure mode available: it asserts the
    print is being watched exactly when it is not. The reporter read that badge and
    concluded the loop had never started. It had been calling the ML API every ten
    seconds and being turned away with a 401 -- invisible because Obico's auth
    layer rejects a bad token before its request log sees it, and because
    successful checks log nothing there either.

    Add two honest states. Not checking (amber) when the last poll produced no
    result, carrying the reason; Starting while a monitored print waits for its
    first result. Score and frame count are withheld while not checking, since
    0.000 beside "Not checking" reads as a measurement rather than its absence.
    The reason is per printer, so a card names its own problem rather than
    whichever printer failed most recently, and stays behind settings:read because
    it can quote configured URLs -- the badge state does not, because whether a
    print is watched is not configuration. An unrecognised class now falls back to
    Starting, not Safe.

    Test Connection saves the form before probing, so a green result describes the
    configuration the loop actually runs with rather than what is typed in the
    boxes.
2026-08-26 10:17:21 +02:00
maziggy bb82fbc337 Decide migration idempotency by SQLSTATE, not by English error text (issue #2949)
PostgreSQL renders its messages in the server's lc_messages locale. _safe_execute
    recognised an already-applied statement by searching the error text for
    "already exists", so a Russian-locale server -- which says "уже существует" --
    re-raised it and aborted startup.

    The column already existing is the expected outcome: create_all() builds the
    tables from the models before the migration list runs, so on a fresh database
    essentially every ADD COLUMN in that list is a duplicate by design, and all 382
    of them relied on that recognition. No PostgreSQL server outside an English
    locale could start Bambuddy at all, fresh install or upgrade.

    Classify on SQLSTATE instead -- 42701, 42P07, 42710, 23505 -- which PostgreSQL
    never translates. The existing narrowing is kept and now rests on a code rather
    than a phrase: a missing column counts as already-applied only for RENAME COLUMN,
    so a missing column during ADD COLUMN or CREATE INDEX still aborts rather than
    hiding a corrupt schema. SQLite keeps the text match; its driver publishes no
    SQLSTATE and it does not localise. The OIDC auto-link constraint read message
    text the same way and gets the same treatment.

    Verified against PostgreSQL 15 under ru_RU, en_US and C: init_db() completes on
    a fresh database and on a re-run in all three, and the schema the Russian server
    ends up with is byte-identical to the English one.
2026-08-26 10:17:03 +02:00
maziggy dd343e0506 Anchor a plug-energy test to local midnight, not the wall clock (issue #2938)
test_nothing_derivable_before_the_first_midnight failed for 31 minutes of
    every day and passed for the other 23.5 hours -- the shape that reads as
    ordinary flakiness and gets re-run rather than fixed. @ojimpo hit it running
    the full suite at 22:10 UTC, stashed his branch to confirm it reproduced on
    clean dev, and measured the window minute by minute instead of guessing.

    The test asserts that nothing can be derived when the only snapshot was
    taken after this local midnight, and it placed that snapshot at a raw
    wall-clock offset -- now minus thirty minutes. Its comment, "taken this
    morning, after midnight", is the premise, and it is only true away from
    the boundary. For the first half hour of each local day, now minus thirty
    minutes lands before local midnight, where it is a perfectly good baseline:
    _counter_at finds it and today comes back 1.5 rather than None.

    The window is local 00:00 to 00:30, which is 22:00 to 22:30 UTC under CEST
    and 23:00 to 23:30 under CET -- it moves with DST, since the module pins
    Europe/Berlin in an autouse fixture and an outer TZ makes no difference.

    Nothing is wrong with the production code. A snapshot from before local
    midnight genuinely is a valid baseline for today, and derive_today_yesterday
    is right to treat it as one. Only the test's premise breaks at the boundary.

    The snapshot is now anchored to local_day_start(now) plus thirty minutes,
    which is the idiom the other nine snapshot writes in this file already use
    and the reason none of them can drift. Replayed across 5760 minutes covering
    four days, including both DST switch days: the old expression fails 31
    minutes per day, the new one fails none.
2026-08-26 10:16:48 +02:00
maziggy 2b72e41027 Updated CHANGELOG 2026-08-26 10:16:32 +02:00
maziggy 5be2151f99 Take an RFID spool's core weight from the row that names it (issue #2909) (#2923) 2026-08-26 10:16:15 +02:00
maziggy 32c945db39 Let one checkbox say where a slice's settings come from (issue #2942)
Two features in the slice dialog read as one. "Use the file's built-in
    settings" slices a 3MF the way its designer set it up, ignoring the picked
    profiles. The per-option "from file" ticks beside each setting carry the
    designer's individual deviations onto the profile you picked, and those
    arrived pre-ticked whatever the checkbox said. So a slice run deliberately
    without the file's settings still took sixteen values out of it -- the
    reporter's log names them, enable_support and support_type among them,
    landing on a process preset they had chosen on purpose.

    The ticks now follow the checkbox. Off, nothing comes out of the file until
    it is asked for by name; on, every setting the file changed shows ticked,
    because on that path the file really does drive the whole slice. Taking the
    designer's work in bulk is still one click, from a line at the top of the
    panel that says how many settings the file changed -- it is the only way
    left to reach them without hunting for chips across six pages of 348
    options -- and it still leaves the machine-tuned keys and the two that are
    the picked preset for a per-key decision.

    The panel greys out options the slicer's own rules switch off, and it was
    evaluating those rules against what the user had typed alone, falling back
    to the compiled-in schema defaults for the rest. A preset with supports on
    therefore read as enable_support: false and greyed out the whole Support
    page while the slice ran supports. A greyed row greyed its tick too, which
    is how the reporter's screenshot shows a support type marked "from file",
    applied to the slice, and impossible to clear. The rules now see what the
    slice will actually run with: the preset's values, the file's values for
    the keys that are on, and anything typed on top. The tick is no longer
    gated on those rules at all -- it answers a different question, not whether
    an option is in play but where its value comes from.

    Underneath both, the support carry-over ran outside the ticks entirely,
    lifting four keys out of any 3MF that had supports on with nothing on
    screen able to decline. It now stands down for the keys that were offered
    and turned down, which the request can say for the first time: an empty
    design_overrides list means the caller was shown the file's settings and
    took none, where no list at all is a caller that predates the choice. That
    distinction is what keeps the carry whole for sources with no deviations to
    tick, an OrcaSlicer export among them, rather than trading one silent
    default for another.

    Worth knowing: a Bambu Studio file with supports enabled no longer switches
    supports on for you. Tick Enable support, or the checkbox above the panel.
    Measured against the reporter's own sixteen keys, and covered by backend
    and frontend tests -- reverting any one of the three changes fails tests.
2026-08-26 10:15:48 +02:00
maziggy 818543c4f6 Say which blue a colour mismatch is about (issue #2941)
A print was refused a colour match between two filaments the dialog itself
    labelled "Blue". The comparison was right: the slicer profile asked for a
    near-pure #0028FF and the slot held Bambu's navy #0A2989, 118 apart in the
    blue channel alone and a CIEDE2000 distance of 15, where 1 is a
    just-noticeable difference. Nothing on screen said so. A hex that misses the
    colour catalogue is named by a coarse family bucket, so both sides resolved
    to the same word, and the warning sat between two identical labels with
    nothing to reconcile it against. Read as a broken matcher, which is what the
    report said, and a fair reading of what was shown.

    Where both sides of a mismatch carry the same name they are now qualified by
    their hex, and the tooltip names them together: "Same type, different color:
    needs Blue (#0028FF), slot has Blue (#0A2989)". Names that already differ are
    left alone -- the hex is noise once the words separate them -- and a side with
    no name falls back to its hex rather than growing an empty bracket.

    The comparison is untouched. It was correct, and its tolerance is not
    something to widen on one report: a difference that size would start matching
    navy to cyan, and eligibility is the same rule the queue scheduler dispatches
    on, so loosening it would change which spool gets printed rather than only
    which warning gets shown. This issue was about being able to see why the
    warning fired, and a test pins that it still fires.

    The strings around it were hardcoded English: the panel's status line, the
    required-filament tooltip, the auto-matched marker, the slot placeholder, the
    mismatch detail and the type-not-found message. All nine now go through
    translation, in all thirteen locales. Two of them -- "Same type, different
    color" and "Filament type not loaded" -- turned out to have had translations
    sitting in every locale file the whole time, unused, while the component
    rendered the English literal beside them. The parity gate could not have
    caught any of this: keys that were never added have nothing to compare
    against.
2026-08-26 10:15:30 +02:00
maziggy b212ba984a Carry a fault's description in the status response (issue #2926)
The HMS catalogue has been in the backend all along and the status
    response never carried it, so every consumer that wanted to tell a user
    why a print halted resolved the same 853 codes from its own duplicate of
    the same sentences -- this repo's Python table, the frontend modal's, and
    at least one third-party client whose catalogue exists purely because the
    server would not say. Each ages separately, and a relay watching a
    printer could only manage "your printer needs attention" while the server
    already knew it was "Filament ran out. Please load new filament."

    hms_errors[] entries now carry a description, defaulting to null so a
    client that has never seen the field is unaffected.

    It is resolved where the fault is parsed rather than at the boundary that
    prompted the request, because there are three serializers of a fault, not
    one: the status response, the WebSocket broadcast, and the completion
    payload the queue's failure reason is built from. Adding it to only the
    first would have handed half the feature to a relay watching the stream,
    which is the likelier consumer of the three. The queue's failure reason
    now quotes the resolved sentence instead of looking the code up a fourth
    time, and the notification path reads it rather than re-deriving. That
    they cannot report different text for one fault is the point, and a test
    asserts they agree.

    describe_fault is the single mapping from either code shape onto the
    table. An 8-char print_error is the catalogue's MMMM_EEEE key with the
    separator removed -- the parser derives full_code and that key from the
    same 32-bit value -- so it resolves exactly. A 16-char hms[] identifier
    is tried whole and then collapsed to its first and last groups.

    That collapse is lossy, and keeping it was the decision worth making
    carefully. #2728 counts 65 documented faults falling onto 0300_0001
    alone, so a hit can attribute a neighbour's sentence to this fault, and
    refusing it looks like the stricter reading. It is not: the notification
    path, the queue's failure-reason helper and the frontend modal have all
    resolved hms[] faults this way for as long as they have existed, and it
    resolves real ones -- a 0500_4038 nozzle mismatch arrives in that shape.
    Declining to collapse would have stopped describing faults that are
    described today, silently suppressed the notifications they raise, and
    left this field null while the UI showed text for the same fault.
    Narrowing it belongs with #2728, where both key spaces can move together.

    So the lookup is exactly what it was, verified rather than asserted:
    a test walks every catalogue code in both fault shapes across all three
    alert levels and checks the result against the derivation this replaces.
    A future change to the lookup cannot quietly stop notifications firing.

    The catalogue ships one language, so the field is English and
    unlocalized, which the schema and the API reference both say next to it.
    The camwall feed is deliberately left alone -- it is code-only because
    its token travels in a URL on a screen, and a readable sentence discloses
    more than the camera picture already does. The frontend keeps resolving
    its own text: switching it would change what filterKnownHMSErrors counts
    across eight call sites, which is #1840 and #2728's argument to have.

    HMSError.message goes with this -- a text field that was never set or
    read anywhere, and an invitation to populate the wrong one now that a
    live description sits beside it.

    -----

    Record a failure code the user can actually look up

    The queue's failure reason formats a fault's module and error into
    MMMM_EEEE, and that one derivation never masked the error to 16 bits. A
    fault arriving from the printer's hms[] array carries its alert level in
    the code's high half, so the label came out as 0500_24038 -- five digits
    in a group that has four. It is not a code anyone can find on Bambu's HMS
    index, and because it matches no catalogue key the sentence explaining
    the failure was dropped along with it, leaving the bare number alone.

    The nozzle-size mismatch behind #1111 is exactly such a fault. Reported
    one way it read "[0500_4038] The nozzle diameter in sliced file is not
    consistent with the current nozzle setting"; reported the other, the same
    physical fault read "[0500_24038]" and nothing else.

    There is already a helper that gets this right, used by the archive's own
    failure-reason lookup, so this calls it instead of keeping a fourth copy
    of the derivation. It also takes the raw integer code the MQTT payload
    carries, which the local version only handled as a string.
2026-08-26 10:14:55 +02:00
maziggy 3da0c18c4d Let a virtual printer be told which address to advertise
BambuStudio reads its FTP upload destination out of net.info[].ip in the
    MQTT status, and the bridge fills that field from the VP's bind address.
    On Docker bridge networking the two are different machines' worth of
    address: slicers reach Bambuddy on the host's LAN IP, the container binds
    something like 172.24.0.2, and that private address is what the slicer was
    handed -- so it opened an FTP connection to an address that does not exist
    on its network and the send stalled around 10%.

    VIRTUAL_PRINTER_ADVERTISE_ADDRESS supplies the address slicers actually
    use, taking precedence over the bind address and over the same-subnet host
    interface the bridge falls back to. The armed log line names its source, so
    (VIRTUAL_PRINTER_ADVERTISE_ADDRESS) against (bind_address) tells an
    operator whether the variable reached the container at all, and the
    not-armed diagnostic now names it as the remedy -- a bridge-network install
    that has not set it is exactly the one that cannot auto-resolve either, and
    until now saw only that nothing worked.

    An environment variable rather than a change to how the advertised address
    is resolved, which is the decision worth recording. The VP already has a
    "Network Interface Override" field, and reading it here is the smaller
    patch, but it feeds SSDP and the certificate SANs only: honouring it would
    silently move the upload destination on every install that has one set --
    the multi-NIC, VLAN and Tailscale setups, which are the ones most likely to
    have been arrived at by hand and least likely to survive being
    second-guessed. Unset, this changes nothing, and a test pins that.

    A value that is not a dotted-quad IPv4 is refused with one warning naming
    it and the previous address is used instead. That direction is deliberate:
    declining to rewrite would put the real printer's IP back in front of the
    slicer, which is the leak the rewrite exists to close, so a typo must not
    be able to reopen it. 0.0.0.0 counts as unset and whitespace is stripped,
    for values pasted into a compose file.

    Host and macvlan networking need none of this and stay what Virtual Printer
    is developed against. The variable removes one blocker; it does not make
    bridge mode equivalent. The wiki said in three places that the host address
    could not be discovered at all, which is no longer true, so those now
    describe the variable and keep the recommendation.
2026-08-26 10:14:27 +02:00
maziggy 5de06cca0f Stop offering AMS slots as places to store a spool
The Storage Location dropdown listed entries like "H2D-1 - AMS A1" next to
        real locations, and they could not be got rid of.

        They were never locations. Bambuddy used to record which slot a spool was
        loaded into by writing that string into Spoolman's location field, and the
        writer went away when Storage Location became something the user picks --
        but the strings stayed on people's Spoolman spools, and the location sync
        imports every distinct one it finds, so they have been coming back in
        through the front door ever since. A printer slot is where a spool is
        loaded, not where it is put away, and slot assignments already track the
        first.

        Deleting one by hand did not work either, which is what made this a dead
        end rather than an annoyance: the delete route refuses a location that has
        spools, and in Spoolman mode it counts them by matching that same string,
        so every marker still sitting on a loaded spool answered 409 -- and the two
        that were empty were back on the next sync a minute later.

        The import now skips them and a one-shot migration clears the ones already
        in the catalogue. The shape is defined once and used by both: an optional
        printer-name prefix followed by AMS A1, AMS-HT A1 or External Spool, which
        is exactly what convert_ams_slot_to_location produced. It stays narrow on
        purpose -- "AMS Drybox" and "Spare AMS trays" are somebody's shelf, and
        anything the filter swallowed would be a place they could no longer file a
        spool under -- so both directions are pinned by tests.

        A row is only removed when no spool in this database points at it, by id or
        by legacy free-text name, so an internal-mode user who has deliberately
        filed spools under such a name keeps it. Spools in Spoolman are neither
        consulted nor touched: their location strings are the user's data on the
        user's server, and one that still reads "H2D-1 - AMS A1" in the inventory
        list is telling the truth about what Spoolman holds. It simply stops being
        offered as a destination.

        Verified on a live Postgres instance carrying the reported symptom: 13
        locations down to 3, all ten markers removed, the two real shelves and one
        hand-typed Spoolman name left alone.
2026-08-26 10:14:09 +02:00
maziggy 8c01e0f8ea Draw a spool the way the AMS described it, and correct the tare it was added with
Two faults in the same auto-add path, both found while tracing why an H2C
        slot named a wood roll as plain PLA.

        A spool's swatch is composed from effect_type and extra_colors, and the
        RFID auto-add set neither. It reads the colour catalogue to name the
        colour and took the name alone, even though the row it had in hand also
        carries those two columns -- the spool form's own colour picker hands both
        to a spool a user adds by hand, so the same roll rendered one way when you
        typed it in and another when the printer identified it for you.

        Both columns now travel with the name. That alone changes nothing on a
        stock install, because the shipped catalogue carries an effect on none of
        its 600-odd rows, so the subtype is read where the catalogue has none: it
        is already derived from what the printer reports, and the two vocabularies
        line up -- Wood, Silk, Sparkle, Marble, Glow, Galaxy, Metal, Rainbow,
        Translucent, Matte, and the Gradient, Dual Color and Tri Color that the
        M*/T* colour codes upgrade a subtype to. "Silk+" reads as Silk, since the
        plus is on the product name rather than the finish. A subtype that names
        no effect -- Basic, Tough, CF -- leaves the column empty rather than
        inventing an overlay, and a value already set is never overwritten, so the
        column stays what it is documented to be: a rendering hint the user can
        override without touching Bambu's categorical label.

        ------

        Correct the spool tare an RFID roll was added with (#2909)

        The lookup that gave an auto-added spool its core_weight asked for the
        first catalogue row whose name starts "Bambu Lab" and took whatever came
        back. There are three, and which is first is the database's business:
        SQLite returns insertion order in practice, Postgres promises nothing once
        a table has seen an update. The same roll was therefore recorded with the
        216 g High Temp tare on one install and correctly with the 250 g Low Temp
        one on another. @ojimpo's forward fix picks the row by name; this repairs
        the rows already written, which the forward fix cannot reach -- 22 of 26
        RFID-added spools on the instance this was traced on.

        The tare is not cosmetic. A spool weighed on SpoolBuddy has its remaining
        filament worked out as the scale reading minus the tare, so a 34 g low
        tare credits the roll with 34 g that is not there and writes a used weight
        34 g short. That error is a constant -- every later print adds to the used
        weight on top of it -- so adding the difference back is exact however much
        has been printed since. It is applied only to spools that have been on the
        scale; one that never was has a used weight derived from the AMS remaining
        percentage, which the tare never entered into.

        Rows are identified by the signature of the broken lookup: added by RFID,
        carrying the weight of one of the other Bambu catalogue rows, with the
        weights read out of the catalogue rather than hardcoded so an install
        whose rows have been re-measured is repaired to its own numbers. Keying on
        whether a catalogue row had been recorded would not have worked -- the
        weight picker auto-selects the only row matching the weight and writes its
        id on the next save, so that column says only whether the form was ever
        opened. The one case that cannot be told apart is stated rather than
        hidden: someone who moved an RFID roll onto a genuine High Temp spool and
        set 216 g by hand is normalised with the rest. Runs exactly once, so a
        tare set afterwards is kept.
2026-08-26 10:13:42 +02:00
maziggy 09d8ca351d Name a spool by its subtype on the slot it is assigned to
A spool's subtype is half of what it is called: "PLA" and "PLA Wood" are
        different filaments. The AMS slot's hover card built the assigned-spool
        line out of brand, material and colour name and left the subtype out, so
        a roll of Bambu PLA Wood Classic Birch in an H2C's A4 was announced as
        "Bambu Lab PLA - Classic Birch".

        Everything else named it correctly at the same moment -- the RFID read,
        the inventory row, the slot's own profile line, which is built from the
        spool's slicer preset rather than reassembled, and Bambu Studio -- so the
        one wrong line read like a bad tag read rather than a display fault.

        It was not only the render. The card's assignedSpool prop had no subtype
        field at all, and the six places the printer card fills it in -- regular
        AMS, AMS-HT and external spool, each in both Spoolman and internal-
        inventory mode -- never passed one, so the value could not reach the
        component. The field is required rather than optional, which is what
        stops the next call site from quietly omitting it; that omission is the
        whole of this bug.

        Three more surfaces rebuilt the name the same way and are fixed with it:
        the SpoolBuddy AMS slot panel in both inventory modes, and the write-tag
        confirmation. Every other place a spool is named -- the assign dialogs,
        the inventory cards, the forecast rows, the label picker -- already
        included the subtype, so these four were the outliers.

        This is the display-side half of #2902, which stopped the backend
        reducing a filled or foamed filament onto its base material. The card was
        doing the same thing to the same spools, one layer further out.
2026-08-26 10:13:19 +02:00
Kouki Ojima 73912d4f05 Let a clear spool stay clear on the way to Spoolman (issue #2912) (#2924) 2026-08-25 13:45:57 +02:00
maziggy d9bc7ae47a Read a NULL notification flag as off instead of dropping every provider (issue #2827)
Adding on_stock_reorder_alert and on_stock_break_alert to the provider
schema made them required on the way out as well as in: the response model
inherits the write model. Every on_* column on notification_providers is
nullable with no server default, and where the table was created from
Base.metadata before run_migrations, the ALTER ... DEFAULT false that
introduced those columns was swallowed as a duplicate and never backfilled
existing rows. Those NULLs were harmless until the flags were read, at
which point the row failed validation -- and a list is validated as a
whole, so one row took every provider with it. The route returned 500 and
the UI rendered an empty list, so configured providers looked deleted.

Backfill them to off, which is what the sender already assumed: it selects
providers with IS TRUE, so a NULL flag never sent anything. A NULL flag now
also reads as off rather than failing the response, across all of them, so
the next flag added to this schema cannot repeat it. Writes are unchanged.
2026-08-25 13:09:24 +02:00
maziggy df57e5213b Updated CHANGELOG 2026-08-25 12:21:19 +02:00
MagicMelody84 54af3146a3 [Feature]: Bind Home Assistant sensors to storage locations (dryboxes/bins) (#2827) 2026-08-25 12:17:23 +02:00
maziggy 5dd7bd213f Read a print's destination from the report topic, not just the request one (issue #1820)
current_project_url was assigned in exactly one place, _handle_request_message,
and _on_message calls that only for the request topic. A print started from the
printer's own screen publishes nothing there, so the field stayed None for the
one case the storage verdict exists for: the file is already in the printer's
model library under /userdata/model/history/, which port 990 does not serve.

The verdict then fell through to the sdcard flag, and @ojimpo's H2S reports that
flag true -- its "card" is the internal eMMC -- so every such print ran the full
sweep before giving up. He measured one: 16 filename-and-directory attempts over
22 FTPS connections, 18 of them refused, 6.4 seconds, then a fallback archive
holding a name and nothing else.

The printer does announce where the file lives, as an unsolicited project_file
*response* on the report topic about two seconds before gcode_state reaches
PREPARE. _process_message now reads the url off it, gated on result SUCCESS and
a non-empty value so a refused dispatch cannot name a file that was never
written. Reading it there rather than only at the request topic also covers an
install neither of us had in view: some brokers refuse the request-topic
subscription, and on those no print of any kind had ever populated the field.

The new branch captures state and nothing else. The "External project_file
payload" diagnostic stays with the request-topic handler: our own dispatch is
echoed on both topics, the request-topic echo lands first and clears
_own_project_file_key, so reusing the diagnostic here would have logged every
Bambuddy-started print as somebody else's. A test pins that.

What the print names is now what gets tried -- the five directories a copy could
be in, rather than the ~110 connections that cannot succeed. The probe is still
worth running: an H2S keeps recently used jobs under /cache and archives them in
full while they last, which is why the reporter's two prints on the same day
behaved differently. Slicer-sent prints are unchanged.

The banner no longer describes a step that never happened. With no reason
recorded, a blank archive fell back to the original wording -- "Store sent files
on external storage" is off in your slicer -- which on that printer is on, and
which the internal-storage wording from #2780 already explains would not help on
an H2. The archive that most needed that explanation was the only one that could
not be given it.

So file:///userdata/ now earns its own reason, internal_history, separate from
the brtc://emmc dispatch case. A dispatch chose internal storage and can be
aimed elsewhere; a print of a file that was already there had no dispatch at
all, and telling that operator to pick External in Send names a dialog they
never opened. The banner and the connection diagnostic both read the verdict's
reason rather than a fixed one, so the two surfaces cannot give the same printer
different advice. Thirteen locales, and a wiki section the banner links to.

-----

Read the K-profile selection when the mutation runs, not when it is captured

Configure Slot sends cali_idx from selectedKProfile, and the mutation read it
through its own closure. React Query hands a mutation its options from an
effect, so a click landing between a commit and that effect flushing runs the
previous render's mutationFn -- one that captured the selection as it was before
the K-profile query resolved. The payload then carries cali_idx -1 and the
printer binds the default 0.020 instead of the calibrated K, while the dialog
shows the right profile selected throughout.

It surfaced as an intermittent failure of the per-nozzle K-profile test, about
one full-suite run in six. Reproducing it with staggered query resolution showed
the divergence directly: the select element held the correct profile immediately
before and after the click, and the payload still carried -1. That test's slot is
the most exposed case in the file -- a right-hotend slot carrying the left
hotend's index, where the "keep showing the active profile" safety net cannot
repair an empty recompute.

The selection now goes through a ref written during render, so the mutation
resolves it at execute time. An effect would have inherited the same flush
ordering this exists to escape. The K value and the profile's ids travel in the
same payload and had the same exposure, so they move with it.

Measured over a staggered-resolution grid: 2 failures in 15 runs before, 0 in 12
after. api.getSlicerPrinterModels was also missing from the test file's mock, so
that query ran with no query function and rejected in all 37 tests -- mocked now,
though on its own it changed nothing, which is how the ref was confirmed as the
fix rather than assumed.
2026-08-25 11:19:46 +02:00
maziggy 4e79f9c2f7 Fill in a fallback archive when the 3MF finally arrives (issue #2957)
A failed TLS handshake pauses a printer's file service for five minutes, and
the archive flow checks that pause at the top of its path loop and gives up
before opening a connection. A print that starts inside one gets an empty
fallback archive 13 milliseconds later, having never touched the network.

Four minutes on, the pause clears and the cover endpoint downloads the same
file, parses it, takes a thumbnail out of it, and publishes it to the shared
3MF cache under the exact key the archive flow looks up. Nothing ever looks:
all three readers of that cache run before or during the print-start handler
that already gave up, and print completion drops the cache as its first act,
deleting the file. No path existed by which a fallback archive could become a
real one.

Offer a later 3MF to the running print's archive, filling the existing row
rather than adding a second -- the row id carries the energy reading, the
timelapse session and the start notification. That covers the reported case at
no network cost, since opening the printer card already downloads the file.

Schedule a bounded retry when the pause is what caused the fallback, spending
the cache first and the printer only if that misses. Not scheduled for the
other cause: a print kept on internal eMMC has no FTPS copy to come back for,
and retrying it is the sweep removed in #2780. The two reasons are now recorded
separately instead of both landing as "no 3MF".

Recovery refuses anything that is not a readable 3MF -- a truncated download
would replace an honest empty archive with wrong metadata -- and leaves an
archive alone once it has a real file.

Three things the recovery path has to get right, each with a test:

Reduce every retry candidate to a bare name. MQTT hands `filename` over as
"/data/Metadata/plate_1.gcode" on some firmware, and joining that onto the temp
directory yields the absolute path itself, so the retry is fed the flow's own
sanitised candidate list and strips the path again on its own account.

Serialise recovery per printer. The cover endpoint's single-flight coalesces by
view, so two views race each other, and the retry task and print completion can
land on top of either -- each reads file_path == "" and runs a full copy,
leaving the row on one timestamped directory and the rest orphaned.

Keep looking for photos in the pre-recovery directory. `archive_dir` derives
from `file_path`, so filling the row moves the archive's directory, and a photo
uploaded to the empty card while the print ran stays where it was put.
2026-08-25 09:33:52 +02:00
maziggy 06e5114a03 Do not report a printer as Safe when nothing is checking it (issue #2952)
The printer card's AI badge collapsed every class that was not Warning or
Failure into green Safe, and the service reported `safe` whenever it had no
verdict. The state entry is created when a monitored print is first seen --
before the first snapshot, let alone the first inference -- so a rejected ML
API token, an unreachable ML API, a failed capture and an unset External URL
all rendered as a healthy watched print: green Safe at score 0.000.

For a safety feature that is the worst failure mode available: it asserts the
print is being watched exactly when it is not. The reporter read that badge and
concluded the loop had never started. It had been calling the ML API every ten
seconds and being turned away with a 401 -- invisible because Obico's auth
layer rejects a bad token before its request log sees it, and because
successful checks log nothing there either.

Add two honest states. Not checking (amber) when the last poll produced no
result, carrying the reason; Starting while a monitored print waits for its
first result. Score and frame count are withheld while not checking, since
0.000 beside "Not checking" reads as a measurement rather than its absence.
The reason is per printer, so a card names its own problem rather than
whichever printer failed most recently, and stays behind settings:read because
it can quote configured URLs -- the badge state does not, because whether a
print is watched is not configuration. An unrecognised class now falls back to
Starting, not Safe.

Test Connection saves the form before probing, so a green result describes the
configuration the loop actually runs with rather than what is typed in the
boxes.
2026-08-25 08:49:24 +02:00
maziggy ea898141f0 Decide migration idempotency by SQLSTATE, not by English error text (issue #2949)
PostgreSQL renders its messages in the server's lc_messages locale. _safe_execute
recognised an already-applied statement by searching the error text for
"already exists", so a Russian-locale server -- which says "уже существует" --
re-raised it and aborted startup.

The column already existing is the expected outcome: create_all() builds the
tables from the models before the migration list runs, so on a fresh database
essentially every ADD COLUMN in that list is a duplicate by design, and all 382
of them relied on that recognition. No PostgreSQL server outside an English
locale could start Bambuddy at all, fresh install or upgrade.

Classify on SQLSTATE instead -- 42701, 42P07, 42710, 23505 -- which PostgreSQL
never translates. The existing narrowing is kept and now rests on a code rather
than a phrase: a missing column counts as already-applied only for RENAME COLUMN,
so a missing column during ADD COLUMN or CREATE INDEX still aborts rather than
hiding a corrupt schema. SQLite keeps the text match; its driver publishes no
SQLSTATE and it does not localise. The OIDC auto-link constraint read message
text the same way and gets the same treatment.

Verified against PostgreSQL 15 under ru_RU, en_US and C: init_db() completes on
a fresh database and on a re-run in all three, and the schema the Russian server
ends up with is byte-identical to the English one.
2026-08-25 07:55:49 +02:00
maziggy ed84f0f74c Anchor a plug-energy test to local midnight, not the wall clock (issue #2938)
test_nothing_derivable_before_the_first_midnight failed for 31 minutes of
every day and passed for the other 23.5 hours -- the shape that reads as
ordinary flakiness and gets re-run rather than fixed. @ojimpo hit it running
the full suite at 22:10 UTC, stashed his branch to confirm it reproduced on
clean dev, and measured the window minute by minute instead of guessing.

The test asserts that nothing can be derived when the only snapshot was
taken after this local midnight, and it placed that snapshot at a raw
wall-clock offset -- now minus thirty minutes. Its comment, "taken this
morning, after midnight", is the premise, and it is only true away from
the boundary. For the first half hour of each local day, now minus thirty
minutes lands before local midnight, where it is a perfectly good baseline:
_counter_at finds it and today comes back 1.5 rather than None.

The window is local 00:00 to 00:30, which is 22:00 to 22:30 UTC under CEST
and 23:00 to 23:30 under CET -- it moves with DST, since the module pins
Europe/Berlin in an autouse fixture and an outer TZ makes no difference.

Nothing is wrong with the production code. A snapshot from before local
midnight genuinely is a valid baseline for today, and derive_today_yesterday
is right to treat it as one. Only the test's premise breaks at the boundary.

The snapshot is now anchored to local_day_start(now) plus thirty minutes,
which is the idiom the other nine snapshot writes in this file already use
and the reason none of them can drift. Replayed across 5760 minutes covering
four days, including both DST switch days: the old expression fails 31
minutes per day, the new one fails none.
2026-08-24 13:29:28 +02:00
maziggy e2a2e06da3 Updated CHANGELOG 2026-08-24 11:53:53 +02:00
Kouki Ojima f95c81e6cd Take an RFID spool's core weight from the row that names it (issue #2909) (#2923) 2026-08-24 11:51:22 +02:00
maziggy b1f5ec9642 Let one checkbox say where a slice's settings come from (issue #2942)
Two features in the slice dialog read as one. "Use the file's built-in
settings" slices a 3MF the way its designer set it up, ignoring the picked
profiles. The per-option "from file" ticks beside each setting carry the
designer's individual deviations onto the profile you picked, and those
arrived pre-ticked whatever the checkbox said. So a slice run deliberately
without the file's settings still took sixteen values out of it -- the
reporter's log names them, enable_support and support_type among them,
landing on a process preset they had chosen on purpose.

The ticks now follow the checkbox. Off, nothing comes out of the file until
it is asked for by name; on, every setting the file changed shows ticked,
because on that path the file really does drive the whole slice. Taking the
designer's work in bulk is still one click, from a line at the top of the
panel that says how many settings the file changed -- it is the only way
left to reach them without hunting for chips across six pages of 348
options -- and it still leaves the machine-tuned keys and the two that are
the picked preset for a per-key decision.

The panel greys out options the slicer's own rules switch off, and it was
evaluating those rules against what the user had typed alone, falling back
to the compiled-in schema defaults for the rest. A preset with supports on
therefore read as enable_support: false and greyed out the whole Support
page while the slice ran supports. A greyed row greyed its tick too, which
is how the reporter's screenshot shows a support type marked "from file",
applied to the slice, and impossible to clear. The rules now see what the
slice will actually run with: the preset's values, the file's values for
the keys that are on, and anything typed on top. The tick is no longer
gated on those rules at all -- it answers a different question, not whether
an option is in play but where its value comes from.

Underneath both, the support carry-over ran outside the ticks entirely,
lifting four keys out of any 3MF that had supports on with nothing on
screen able to decline. It now stands down for the keys that were offered
and turned down, which the request can say for the first time: an empty
design_overrides list means the caller was shown the file's settings and
took none, where no list at all is a caller that predates the choice. That
distinction is what keeps the carry whole for sources with no deviations to
tick, an OrcaSlicer export among them, rather than trading one silent
default for another.

Worth knowing: a Bambu Studio file with supports enabled no longer switches
supports on for you. Tick Enable support, or the checkbox above the panel.
Measured against the reporter's own sixteen keys, and covered by backend
and frontend tests -- reverting any one of the three changes fails tests.
2026-08-24 11:04:33 +02:00
maziggy cbbdab86f9 Say which blue a colour mismatch is about (issue #2941)
A print was refused a colour match between two filaments the dialog itself
labelled "Blue". The comparison was right: the slicer profile asked for a
near-pure #0028FF and the slot held Bambu's navy #0A2989, 118 apart in the
blue channel alone and a CIEDE2000 distance of 15, where 1 is a
just-noticeable difference. Nothing on screen said so. A hex that misses the
colour catalogue is named by a coarse family bucket, so both sides resolved
to the same word, and the warning sat between two identical labels with
nothing to reconcile it against. Read as a broken matcher, which is what the
report said, and a fair reading of what was shown.

Where both sides of a mismatch carry the same name they are now qualified by
their hex, and the tooltip names them together: "Same type, different color:
needs Blue (#0028FF), slot has Blue (#0A2989)". Names that already differ are
left alone -- the hex is noise once the words separate them -- and a side with
no name falls back to its hex rather than growing an empty bracket.

The comparison is untouched. It was correct, and its tolerance is not
something to widen on one report: a difference that size would start matching
navy to cyan, and eligibility is the same rule the queue scheduler dispatches
on, so loosening it would change which spool gets printed rather than only
which warning gets shown. This issue was about being able to see why the
warning fired, and a test pins that it still fires.

The strings around it were hardcoded English: the panel's status line, the
required-filament tooltip, the auto-matched marker, the slot placeholder, the
mismatch detail and the type-not-found message. All nine now go through
translation, in all thirteen locales. Two of them -- "Same type, different
color" and "Filament type not loaded" -- turned out to have had translations
sitting in every locale file the whole time, unused, while the component
rendered the English literal beside them. The parity gate could not have
caught any of this: keys that were never added have nothing to compare
against.
2026-08-24 10:14:31 +02:00
maziggy 6988a30eae Carry a fault's description in the status response (issue #2926)
The HMS catalogue has been in the backend all along and the status
response never carried it, so every consumer that wanted to tell a user
why a print halted resolved the same 853 codes from its own duplicate of
the same sentences -- this repo's Python table, the frontend modal's, and
at least one third-party client whose catalogue exists purely because the
server would not say. Each ages separately, and a relay watching a
printer could only manage "your printer needs attention" while the server
already knew it was "Filament ran out. Please load new filament."

hms_errors[] entries now carry a description, defaulting to null so a
client that has never seen the field is unaffected.

It is resolved where the fault is parsed rather than at the boundary that
prompted the request, because there are three serializers of a fault, not
one: the status response, the WebSocket broadcast, and the completion
payload the queue's failure reason is built from. Adding it to only the
first would have handed half the feature to a relay watching the stream,
which is the likelier consumer of the three. The queue's failure reason
now quotes the resolved sentence instead of looking the code up a fourth
time, and the notification path reads it rather than re-deriving. That
they cannot report different text for one fault is the point, and a test
asserts they agree.

describe_fault is the single mapping from either code shape onto the
table. An 8-char print_error is the catalogue's MMMM_EEEE key with the
separator removed -- the parser derives full_code and that key from the
same 32-bit value -- so it resolves exactly. A 16-char hms[] identifier
is tried whole and then collapsed to its first and last groups.

That collapse is lossy, and keeping it was the decision worth making
carefully. #2728 counts 65 documented faults falling onto 0300_0001
alone, so a hit can attribute a neighbour's sentence to this fault, and
refusing it looks like the stricter reading. It is not: the notification
path, the queue's failure-reason helper and the frontend modal have all
resolved hms[] faults this way for as long as they have existed, and it
resolves real ones -- a 0500_4038 nozzle mismatch arrives in that shape.
Declining to collapse would have stopped describing faults that are
described today, silently suppressed the notifications they raise, and
left this field null while the UI showed text for the same fault.
Narrowing it belongs with #2728, where both key spaces can move together.

So the lookup is exactly what it was, verified rather than asserted:
a test walks every catalogue code in both fault shapes across all three
alert levels and checks the result against the derivation this replaces.
A future change to the lookup cannot quietly stop notifications firing.

The catalogue ships one language, so the field is English and
unlocalized, which the schema and the API reference both say next to it.
The camwall feed is deliberately left alone -- it is code-only because
its token travels in a URL on a screen, and a readable sentence discloses
more than the camera picture already does. The frontend keeps resolving
its own text: switching it would change what filterKnownHMSErrors counts
across eight call sites, which is #1840 and #2728's argument to have.

HMSError.message goes with this -- a text field that was never set or
read anywhere, and an invitation to populate the wrong one now that a
live description sits beside it.

-----

Record a failure code the user can actually look up

The queue's failure reason formats a fault's module and error into
MMMM_EEEE, and that one derivation never masked the error to 16 bits. A
fault arriving from the printer's hms[] array carries its alert level in
the code's high half, so the label came out as 0500_24038 -- five digits
in a group that has four. It is not a code anyone can find on Bambu's HMS
index, and because it matches no catalogue key the sentence explaining
the failure was dropped along with it, leaving the bare number alone.

The nozzle-size mismatch behind #1111 is exactly such a fault. Reported
one way it read "[0500_4038] The nozzle diameter in sliced file is not
consistent with the current nozzle setting"; reported the other, the same
physical fault read "[0500_24038]" and nothing else.

There is already a helper that gets this right, used by the archive's own
failure-reason lookup, so this calls it instead of keeping a fourth copy
of the derivation. It also takes the raw integer code the MQTT payload
carries, which the local version only handled as a string.
2026-08-24 09:47:39 +02:00
maziggy c094102614 Let a virtual printer be told which address to advertise
BambuStudio reads its FTP upload destination out of net.info[].ip in the
MQTT status, and the bridge fills that field from the VP's bind address.
On Docker bridge networking the two are different machines' worth of
address: slicers reach Bambuddy on the host's LAN IP, the container binds
something like 172.24.0.2, and that private address is what the slicer was
handed -- so it opened an FTP connection to an address that does not exist
on its network and the send stalled around 10%.

VIRTUAL_PRINTER_ADVERTISE_ADDRESS supplies the address slicers actually
use, taking precedence over the bind address and over the same-subnet host
interface the bridge falls back to. The armed log line names its source, so
(VIRTUAL_PRINTER_ADVERTISE_ADDRESS) against (bind_address) tells an
operator whether the variable reached the container at all, and the
not-armed diagnostic now names it as the remedy -- a bridge-network install
that has not set it is exactly the one that cannot auto-resolve either, and
until now saw only that nothing worked.

An environment variable rather than a change to how the advertised address
is resolved, which is the decision worth recording. The VP already has a
"Network Interface Override" field, and reading it here is the smaller
patch, but it feeds SSDP and the certificate SANs only: honouring it would
silently move the upload destination on every install that has one set --
the multi-NIC, VLAN and Tailscale setups, which are the ones most likely to
have been arrived at by hand and least likely to survive being
second-guessed. Unset, this changes nothing, and a test pins that.

A value that is not a dotted-quad IPv4 is refused with one warning naming
it and the previous address is used instead. That direction is deliberate:
declining to rewrite would put the real printer's IP back in front of the
slicer, which is the leak the rewrite exists to close, so a typo must not
be able to reopen it. 0.0.0.0 counts as unset and whitespace is stripped,
for values pasted into a compose file.

Host and macvlan networking need none of this and stay what Virtual Printer
is developed against. The variable removes one blocker; it does not make
bridge mode equivalent. The wiki said in three places that the host address
could not be discovered at all, which is no longer true, so those now
describe the variable and keep the recommendation.
2026-08-24 08:58:41 +02:00
maziggy 537b4d2509 Stop offering AMS slots as places to store a spool
The Storage Location dropdown listed entries like "H2D-1 - AMS A1" next to
    real locations, and they could not be got rid of.

    They were never locations. Bambuddy used to record which slot a spool was
    loaded into by writing that string into Spoolman's location field, and the
    writer went away when Storage Location became something the user picks --
    but the strings stayed on people's Spoolman spools, and the location sync
    imports every distinct one it finds, so they have been coming back in
    through the front door ever since. A printer slot is where a spool is
    loaded, not where it is put away, and slot assignments already track the
    first.

    Deleting one by hand did not work either, which is what made this a dead
    end rather than an annoyance: the delete route refuses a location that has
    spools, and in Spoolman mode it counts them by matching that same string,
    so every marker still sitting on a loaded spool answered 409 -- and the two
    that were empty were back on the next sync a minute later.

    The import now skips them and a one-shot migration clears the ones already
    in the catalogue. The shape is defined once and used by both: an optional
    printer-name prefix followed by AMS A1, AMS-HT A1 or External Spool, which
    is exactly what convert_ams_slot_to_location produced. It stays narrow on
    purpose -- "AMS Drybox" and "Spare AMS trays" are somebody's shelf, and
    anything the filter swallowed would be a place they could no longer file a
    spool under -- so both directions are pinned by tests.

    A row is only removed when no spool in this database points at it, by id or
    by legacy free-text name, so an internal-mode user who has deliberately
    filed spools under such a name keeps it. Spools in Spoolman are neither
    consulted nor touched: their location strings are the user's data on the
    user's server, and one that still reads "H2D-1 - AMS A1" in the inventory
    list is telling the truth about what Spoolman holds. It simply stops being
    offered as a destination.

    Verified on a live Postgres instance carrying the reported symptom: 13
    locations down to 3, all ten markers removed, the two real shelves and one
    hand-typed Spoolman name left alone.
2026-08-23 15:17:09 +02:00
maziggy b38022ec5c Draw a spool the way the AMS described it, and correct the tare it was added with
Two faults in the same auto-add path, both found while tracing why an H2C
    slot named a wood roll as plain PLA.

    A spool's swatch is composed from effect_type and extra_colors, and the
    RFID auto-add set neither. It reads the colour catalogue to name the
    colour and took the name alone, even though the row it had in hand also
    carries those two columns -- the spool form's own colour picker hands both
    to a spool a user adds by hand, so the same roll rendered one way when you
    typed it in and another when the printer identified it for you.

    Both columns now travel with the name. That alone changes nothing on a
    stock install, because the shipped catalogue carries an effect on none of
    its 600-odd rows, so the subtype is read where the catalogue has none: it
    is already derived from what the printer reports, and the two vocabularies
    line up -- Wood, Silk, Sparkle, Marble, Glow, Galaxy, Metal, Rainbow,
    Translucent, Matte, and the Gradient, Dual Color and Tri Color that the
    M*/T* colour codes upgrade a subtype to. "Silk+" reads as Silk, since the
    plus is on the product name rather than the finish. A subtype that names
    no effect -- Basic, Tough, CF -- leaves the column empty rather than
    inventing an overlay, and a value already set is never overwritten, so the
    column stays what it is documented to be: a rendering hint the user can
    override without touching Bambu's categorical label.

    ------

    Correct the spool tare an RFID roll was added with (#2909)

    The lookup that gave an auto-added spool its core_weight asked for the
    first catalogue row whose name starts "Bambu Lab" and took whatever came
    back. There are three, and which is first is the database's business:
    SQLite returns insertion order in practice, Postgres promises nothing once
    a table has seen an update. The same roll was therefore recorded with the
    216 g High Temp tare on one install and correctly with the 250 g Low Temp
    one on another. @ojimpo's forward fix picks the row by name; this repairs
    the rows already written, which the forward fix cannot reach -- 22 of 26
    RFID-added spools on the instance this was traced on.

    The tare is not cosmetic. A spool weighed on SpoolBuddy has its remaining
    filament worked out as the scale reading minus the tare, so a 34 g low
    tare credits the roll with 34 g that is not there and writes a used weight
    34 g short. That error is a constant -- every later print adds to the used
    weight on top of it -- so adding the difference back is exact however much
    has been printed since. It is applied only to spools that have been on the
    scale; one that never was has a used weight derived from the AMS remaining
    percentage, which the tare never entered into.

    Rows are identified by the signature of the broken lookup: added by RFID,
    carrying the weight of one of the other Bambu catalogue rows, with the
    weights read out of the catalogue rather than hardcoded so an install
    whose rows have been re-measured is repaired to its own numbers. Keying on
    whether a catalogue row had been recorded would not have worked -- the
    weight picker auto-selects the only row matching the weight and writes its
    id on the next save, so that column says only whether the form was ever
    opened. The one case that cannot be told apart is stated rather than
    hidden: someone who moved an RFID roll onto a genuine High Temp spool and
    set 216 g by hand is normalised with the rest. Runs exactly once, so a
    tare set afterwards is kept.
2026-08-23 14:25:45 +02:00
maziggy 87e0a4c3b6 Name a spool by its subtype on the slot it is assigned to
A spool's subtype is half of what it is called: "PLA" and "PLA Wood" are
    different filaments. The AMS slot's hover card built the assigned-spool
    line out of brand, material and colour name and left the subtype out, so
    a roll of Bambu PLA Wood Classic Birch in an H2C's A4 was announced as
    "Bambu Lab PLA - Classic Birch".

    Everything else named it correctly at the same moment -- the RFID read,
    the inventory row, the slot's own profile line, which is built from the
    spool's slicer preset rather than reassembled, and Bambu Studio -- so the
    one wrong line read like a bad tag read rather than a display fault.

    It was not only the render. The card's assignedSpool prop had no subtype
    field at all, and the six places the printer card fills it in -- regular
    AMS, AMS-HT and external spool, each in both Spoolman and internal-
    inventory mode -- never passed one, so the value could not reach the
    component. The field is required rather than optional, which is what
    stops the next call site from quietly omitting it; that omission is the
    whole of this bug.

    Three more surfaces rebuilt the name the same way and are fixed with it:
    the SpoolBuddy AMS slot panel in both inventory modes, and the write-tag
    confirmation. Every other place a spool is named -- the assign dialogs,
    the inventory cards, the forecast rows, the label picker -- already
    included the subtype, so these four were the outliers.

    This is the display-side half of #2902, which stopped the backend
    reducing a filled or foamed filament onto its base material. The card was
    doing the same thing to the same spools, one layer further out.
2026-08-23 13:52:32 +02:00
maziggy 490f72fe21 Updated CHANGELOG 2026-08-23 13:07:17 +02:00
maziggy 22ede4a128 Split the Windows installer build so signing can wait for approval
The SignPath Foundation production certificate does not sign on demand
    the way the self-signed test certificate does. Every request has to be
    approved by hand in the SignPath UI, because the Foundation verifies
    what is being signed and which build produced it. The submitting action
    waits for that approval with a default timeout of 600 seconds, which is
    ample while the test policy approves in seconds and far too short once
    the wait is a person noticing a tag went out. A tag pushed at night
    would have failed the run ten minutes later with the installer already
    compiled and thrown away.

    The compile now ends in its own job, which uploads the unsigned artifact
    and exposes its id. A second job downloads it, signs it, and does the
    release-facing work, with the wait raised to an hour and the job timeout
    sized to sit outside it. Separating them is what buys the recovery: the
    artifact is uploaded before the wait begins and is addressed by id, so a
    missed approval window costs a re-run of the second job alone rather
    than a rebuild. Raising the timeout in place would not have given that.

    The second job runs for unsigned builds too. Daily prereleases are
    deliberately left unsigned to preserve the signing quota, and gating the
    whole job on the signing decision would have meant a second copy of the
    alias, artifact and release steps for them to run through.

    The decision itself moves into a named step that echoes it, so a tag
    that came out unsigned can be explained from the run log rather than by
    re-reading the expression. It is one source of truth feeding both jobs,
    which a job-level env could not be.

    Every step body is otherwise unchanged. The property worth keeping is
    that none of the alias, upload and release-attach steps carry always(),
    so GitHub skips all three when signing fails or times out and an
    unsigned .exe cannot reach a release; that is now written next to them,
    because it is easy to break by adding a condition without noticing.

    The policy slug stays at test-signing and the signature check stays
    lenient -- the test certificate is self-signed and reports UnknownError,
    so requiring Valid would fail every run until the production certificate
    is imported. Both are the cutover. The restructure behaves identically
    under the test policy, the request simply completing at once instead of
    waiting, so it can be proven green beforehand.
2026-08-23 12:47:25 +02:00
maziggy f27b82355a Updated BACKERS 2026-08-23 12:47:11 +02:00
maziggy 7f74f831c4 Let a filled or foamed filament keep its own name (issue #2902)
The reduction that gave an AMS slot a material type read PLA-AERO,
    PLA-GF, ASA-GF and PPS-GF as their base material, so a slot loaded with
    foaming or glass-filled filament went out saying plain PLA or ASA. That
    is worse than the bug it replaced. "PLA-AERO" matched nothing before,
    which was useless but honest; "PLA" matches every PLA plate in the
    queue, so the dispatcher would have sent one to filament that will not
    print it -- and the contract the first fix claimed, that it could only
    ever repair a slot, no longer held. @doncaruana caught PLA Aero on the
    issue.

    All four are values Bambuddy itself offers: filament_fields.json is the
    material list the Profiles editor puts in a dropdown, and the reduction
    table was assembled from the cloud filament names and the frontend
    preset parser without ever being checked against it. It is checked now,
    so the next type added to one and not the other fails a test rather than
    a print. ASA-AERO joins them from the cloud catalogue (GFB02).

    The table hyphenates because the slicers do, while a spool says "PLA
    Aero" and every Bambu preset name says "Bambu PLA Aero". Adjacent words
    are joined and taken when the join is a type exactly -- exactly, because
    letting the prefix and suffix rules reach across a space would make
    "Support for PLA" a type by its tail.

    Also from @doncaruana, and the better half of his point: a preset is
    chosen from a list the slicer defines, so it already knows its own type
    and nothing has to be read out of a product name. The resolver now hands
    that answer back and both assign routes prefer it. It cannot be the only
    source -- material is required on a spool and slicer_filament is not, and
    the spool this issue was reported for had no preset at all -- so the
    reduction stays as the fallback for spools without one.

    Two things had to move with it. The auto-unlink guard compared the
    slot's reported type against the reduced material, so a spool whose
    preset outranked its material column would have been unlinked from the
    slot it had just been assigned to; it now accepts any type the assign
    path could have written. And two lookups keyed by material took the
    catch-all for a type they had no row for, which sent an ASA-GF spool out
    at 200/240 -- too cold to extrude -- and preheated its chamber to
    nothing. Both fall back to the base material last, so PLA-CF, PETG-CF
    and PA-CF keep the rows they are listed with, and ASA-CF and ABS-GF pick
    up ranges they had been missing all along.

    What counts as a material name is decided by the base for the same
    reason: saying yes throws the value away and rescues the slot from the
    generic-material fallback, so the answer has to be no when that fallback
    has nothing to offer. ABS-GF reduces to a generic ABS the printer can
    resolve; PPS-CF reduces to nothing and is left as it stands. Adding a
    type to the table therefore cannot quietly change that answer, which is
    how these five slipped through in the first place.

    ------

    Hand the bundled chamber-preheat table back the way it is read

    Every lookup of the per-filament chamber map happens after the keys are
    upper-cased, and the parser documents exactly that: keys uppercased,
    DEFAULT always present so the resolution loop can index it
    unconditionally. The three fallback paths returned the bundled constant
    as declared, with the lowercase "default" row the Settings editor writes
    and displays, so an install that had never opened the setting got a dict
    the loop could not read its fallback out of and used a hardcoded 0 for
    any filament without a row of its own.

    It reported the right number only because that bundled default is 0.
    Raising it would have changed nothing for everyone who had not
    customised the map, with the map in Settings still showing the value
    that was not being used.

    The test that should have caught this asserted the fallback under either
    spelling, and its docstring contradicted itself between title and
    comment. It pins the contract now, over all four ways the parser can
    fall back.
2026-08-23 12:46:48 +02:00
maziggy 7fa9967db3 Point the Watchtower recommendation at the maintained fork (issue #2917) 2026-08-23 12:46:29 +02:00
maziggy 147042cc64 Updated BACKERS 2026-08-23 12:46:19 +02:00
maziggy 6672062859 Light the generated thumbnails so one model differs from another (#2816) (#2861) 2026-08-23 12:46:04 +02:00
maziggy 3f78ce8119 Post work PR #2853 2026-08-23 12:45:52 +02:00
maziggy 89f3b1cc58 Add printer video downloads and range selection (#2853) 2026-08-23 12:45:39 +02:00
maziggy a004581a88 Report the selected plate on the archives API (#2796) (#2871) 2026-08-23 12:45:25 +02:00
maziggy 73db4f174e Register a Spoolman extra field with its own write (issue #2903)
Spoolman rejects a spool whose extra dict carries a key it has not been
    told about, answering 400 "Unknown extra field tag.". Bambuddy keeps the
    tray UUID in extra.tag, so that key has to exist before the first spool
    is created. Registration ran from three hand-maintained lists that fire
    when the integration is set up -- the connect route, startup, and two
    inline blocks in the inventory routes. Enabling Spoolman from the
    Settings page reaches none of them, so the first AMS sync on a fresh
    Spoolman failed on every slot while vendor and filament creation
    succeeded.

    Neither fix suggested on the issue is quite the right shape. Adding the
    block to PUT /settings/spoolman fixes this path and makes a third copy
    of a list that has already drifted -- it would still omit
    bambu_color_name. Ensuring at the sync entry point leaves the other four
    tag writers alone: linking and unlinking a tag, and both inventory edit
    paths.

    So the registration moved to the write. create_spool, update_spool and
    update_spool_full each register the keys of the extra dict they are
    about to send, once per client, before sending. Every tag writer funnels
    through one of the three, merge_spool_extra included. This closes the
    class rather than the instance: a write that carries a key is a write
    that registers it, and bambu_color_name shows why that matters -- it
    never made it into the connect or startup lists at all, and works today
    only because two call sites remembered it by hand.

    Best-effort, deliberately. ensure_extra_field already logs and returns
    False rather than raising, so a registration that fails leaves the write
    to be attempted and to report exactly what it reported before. Failures
    are not memoised either, so a Spoolman that was merely restarting gets
    another try on the next write.

    The older blocks stay. They are redundant now, but the inventory routes'
    inline calls are pinned by tests that assert them against a mocked
    client, where the funnel cannot run.

    The status endpoint is the other half. The Connect button would have
    registered the fields, and the reason nobody reaches it is that
    GET /spoolman/status reported "connected" whenever an earlier request
    had left a client object behind. Roughly twenty call sites build one
    lazily, and saving the Settings page builds one as a side effect of
    syncing locations, so the flag turned on which page had been opened
    rather than on anything about Spoolman. The UI reads it twice -- Connect
    only while disconnected, the sync section only while connected -- so
    those two controls landed in states the user cannot explain. It now asks
    the Spoolman that is configured, including the stale-URL check every
    other route already does, and does not probe at all when the integration
    is switched off, which used to let a leftover client report a disabled
    Spoolman as connected.

    That leaves nothing for Disconnect to do, so it is gone. Spoolman is a
    stateless HTTP API with no session to close; the button dropped the
    client object, the next request rebuilt it lazily, and the status
    flipped back on its own within the 30s poll -- an action that looked
    like it worked and then quietly undid itself. The enable toggle owns
    turning the integration off. Connect stays as what it always was in
    practice, a way to re-check a Spoolman that is not answering, and is
    shown only then. Its translation keys are left in place; only the
    control is removed.

    Resolving the client there means the status poll can now fail in ways a
    read-only check could not, so it no longer reports failure by failing.
    Replacing a client closes the previous one and httpx's aclose() is not
    guaranteed not to raise; a poll that runs every 30 seconds answering 500
    is worse than one answering what is true either way, which is that
    Spoolman could not be reached. The SSRF rejection keeps its own message
    rather than folding into the general one -- a URL the guard refuses is
    the admin's to correct, and that is only actionable if the log says so.

    Tests drive the real client against a fake Spoolman that enforces the
    unknown-extra-field rule rather than mocking it away, so each one fails
    against the old code for the reason the reporter's install did. The
    concurrency test needed the fake to be async: MockTransport answers
    without suspending, so the first version ran each request to completion
    in turn and passed just as happily with the lock removed.
2026-08-23 12:44:45 +02:00
maziggy f4166bf652 Give an AMS slot a material type, not a product name (issue #2902)
Assigning a spool wrote its material straight into the slot's tray_type.
    A slot that says "PLA+" satisfies nothing that asks for PLA: not
    OrcaSlicer, not Bambu Studio, and not Bambuddy's own dispatch matcher,
    which compares the type the printer reports to the one the 3MF declares
    as plain equality. The reporter's slot was unusable for every PLA plate
    he had.

    Not only a label, either. The same string went into the generic-filament
    lookup, which missed, so the slot went out with no tray_info_idx at all
    -- the half-configured state #2604 documents the printer as reverting
    from -- and took the 200/240 catch-all nozzle range instead of PLA's
    190/230.

    PLA+ is not a special case. Bambuddy's own colour catalogue supplies the
    material dropdown, and around forty of its values are vendor product
    lines rather than filament types: HTPLA, PolyTerra PLA, PLA Matte, ASA
    Extrafill, Flexfill TPU 98A.

    So the four routes that configure a slot reduce the material to a name
    the printer knows before sending it, and the product name moves to
    tray_sub_brands -- which is where Bambu Lab puts it too: their catalogue
    carries a preset named "eSUN PLA+" whose type is PLA. A name the
    reduction cannot place is sent exactly as before rather than guessed at,
    so this can only repair a slot, never break a working one. The spool's
    own wording still leads the id and temperature lookups with the reduced
    type appended behind it, so "PETG HF" keeps its own generic preset
    (GFG96) rather than being traded down to plain PETG's.

    Two guards decide whether a candidate filament id is really a material
    name -- the resolver's, which discards one, and slot reuse, which will
    not carry one forward. Both saw only bare types, so "PLA+" passed as a
    filament id. They share one answer now, which also refuses to read an
    id-shaped value: "GFPLA" ends in a material name, and reducing it would
    throw away the calibrated preset in the slot.

    One thing had to move with it. on_ams_change auto-unlinks an assignment
    whose slot stopped looking the way it did when the spool was assigned,
    and the check that spares a slot Bambuddy itself reconfigured compared
    the printer's reported type against the spool's raw material. With the
    slot now carrying the reduced type, every spool this issue is about
    would have been unlinked from the slot it had just been assigned to.
    Both sides are reduced there -- the printer's too, so slots configured
    by an older version, still reporting "PLA+", keep matching.

    Something starts working as a result: a slot holding a calibrated preset
    is reused when a same-material spool is assigned to it, which could not
    happen for these spools while "PLA" and "PLA+" compared unequal.

    Reverting any one of the behaviours above fails a distinct test -- the
    reduction's four matching rules and its pass-through contract included,
    since that contract is what makes the rest of it safe.
2026-08-23 12:44:18 +02:00
maziggy dd7ffe10a5 Ask a printer that refuses FTPS what it actually said (issue #2780)
@grolmus measured a 9-printer farm and the numbers settle what this
    failure is not. Reproduced here, three results:

      cleartext "421" banner on the TLS port
        -> [SSL: WRONG_VERSION_NUMBER] wrong version number (_ssl.c:1032)
      1.2-only server, client forced to 1.3
        -> [SSL: TLSV1_ALERT_PROTOCOL_VERSION]
      1.2-only server, an uncapped client
        -> negotiates 1.2 and connects

    The first is byte-for-byte what the farm logs. So WRONG_VERSION_NUMBER
    means the printer's first bytes were not a TLS record, a version
    mismatch cannot produce it, and reaching a 1.2-only peer needs no cap.

    What it still does not say is WHICH cleartext message, and that is the
    part that would name the fault. OpenSSL has eaten those bytes by the
    time the exception surfaces, so on this error the client now opens one
    plain connection and reads them. The log then carries the printer's own
    words -- an FTP refusal such as "421 Too many connections" would settle
    it outright -- marked as the line to quote in a report. This gets the
    answer from every affected install rather than from the one farm able
    to take a packet capture.

    Three things keep it from making the suspected fault worse:

    - The failed socket is closed BEFORE the probe opens its connection.
      Holding a dead handshake open across a second connect to a printer
      that may be out of connection slots is the leak #2780's own cleanup
      was added to stop.
    - It asks once per cool-off window, not once per attempt. Checked
      before the new deadline is written, so a live entry means an earlier
      failure already asked -- which matters because a dispatch ignores the
      cool-off (#2898) and reaches this branch four times.
    - Connect and read share one timeout budget rather than getting one
      each.

    Only WRONG_VERSION_NUMBER is probed. A protocol-version alert means the
    peer did speak TLS, so there is nothing in the clear to read and the
    probe would only sit out its timeout. A vsFTPd answering its connection
    limit by accepting and staying silent -- the other half of the standing
    theory -- arrives as a handshake timeout and lands on that branch
    instead; there is a test saying so, because widening the trigger later
    would look like an improvement.

    The profile registry is corrected to what was measured. Its docstring
    claimed "the P2S evidently does offer 1.3"; six P2S units refuse it.
    Worse, the X2D (#1638) and H2C (#2582) entries were capped on the
    reading that WRONG_VERSION_NUMBER came from a TLS-1.3 ClientHello,
    which cannot happen -- so the cap is not what changed those outcomes
    and both are now marked RE-TEST WANTED. They are kept rather than
    removed: their reporters saw the symptom clear, nobody here has that
    hardware, and the entry costs nothing on a printer that does not offer
    1.3 anyway. The P2S entry (#1401) is a different symptom -- a 426
    truncation mid-transfer -- and is the only one a session-ticket problem
    could explain, though grolmus's firmware refuses 1.3 there too.

    Both measurements are pinned by tests, so the explanation stays
    falsifiable instead of becoming the next set of confident wrong
    comments. Two existing cool-off tests now count two connections where
    they counted one; the promise they exist for -- contacted twice, not
    ~110 -- is unchanged, and they say why rather than carrying a new
    number.
2026-08-23 12:43:52 +02:00
maziggy 6b23666723 Say why an upload failed instead of blaming the SD card (issue #2899)
Every dispatch upload that failed carried one sentence: "Failed to
    upload file to printer. Check if SD card is inserted and properly
    formatted (FAT32/exFAT)." The reporter got it after a TLS handshake
    failure and restarted the printer on the strength of it. That could not
    have helped. The handshake never reached the printer's filesystem, and
    the cool-off that made the next dispatch fail the same way lives in
    Bambuddy's own memory, where power-cycling a printer does not reach.

    because the advice was known not to work. It survived in the string
    people actually read, so the failure mode that fix closed was still
    reachable through the UI.
    reachable through the UI.

    The information was never missing. connect() separates five failure
    classes and upload_file() separates 553/552/550, each with its own log
    line -- 553 even logs a spelled-out list of storage causes -- and then
    both returned a bare False. The dispatch had nothing left to work with
    and guessed storage for all of them.

    So the reason now travels with the result. The client records an
    FtpFailure (kind, detail, and the reply code where the server gave one)
    on every failure branch, and upload_file_async fills in a report object
    the CALLER owns. Not a per-IP dict beside _mode_cache: those describe a
    printer and are right to share, while this describes one operation, and
    a background timelapse fetch running beside a dispatch would overwrite
    the dispatch's reason with its own -- reporting the wrong cause with
    total confidence, which is this bug again rather than a fix for it.

    describe_upload_failure() picks the wording, and lives next to the kinds
    so the two cannot drift. A 553 or 552 keeps the card advice, which is
    the case it was written for, and quotes the reply code so a queue entry
    and a support bundle line up. A handshake failure says the file service
    answered without TLS and that the card is not involved. A refusal points
    at the access code, a timeout at the network, and anything unclassified
    says so and points at the log rather than picking a plausible cause. A
    wrong instruction costs more than a vague one: it sends someone to work
    on hardware that is fine. Nothing prescribes a power cycle.

    The failure notification now carries the same sentence the queue shows,
    instead of its own fixed "Failed to upload file to printer", so a push
    and the screen cannot disagree about what happened.

    Tests cover the classification, the report reaching the caller through
    the retry loop, two callers not crossing, the queue entry itself, and
    one line per message branch -- without that last one, deleting the
    access-code or timeout branch left every other assertion passing, since
    they only check that the card is not named and the generic message
    satisfies that too. The card-advice test keys on the instruction rather
    than the words "SD card", because the handshake message names the card
    in order to rule it out.
2026-08-23 12:43:25 +02:00
maziggy a1e5afbd2d Keep a dispatch's retries out of the FTPS cool-off (issue #2898)
A failed TLS handshake arms a 300s per-IP cool-off, and connect()
    consulted it for every caller. A print dispatch retries after 2s, so
    once the cool-off was armed all four attempts were answered from the
    gate rather than the network, and every further job queued for that
    printer failed the same way for the rest of the window. The reporter's
    farm lost three jobs to one handshake error, with the retry budget
    contributing nothing to any of them.

    The gate was serving two callers that want opposite things from it. The
    background sweeps -- the post-print 3MF, cover and timelapse fetches --
    walk ~110 candidate paths against one wedged printer with nobody
    waiting, and backing off for minutes is right for them. A dispatch is
    one delete plus at most four upload attempts with someone watching a
    progress bar. So the split is by caller: a client built with
    respect_handshake_cooloff=False goes to the printer regardless, and the
    dispatch's delete and upload -- and a firmware upload, same shape --
    opt out. Everything else keeps #2780's behaviour untouched.

    In the reported trace it is the pre-upload delete that takes the SSL
    error and arms the cool-off, 8ms before the upload's first attempt, so
    exempting the upload alone would have left one dispatch's worth of the
    problem in place.

    Callers that do respect the cool-off no longer sleep out a retry loop
    against it: with_ftp_retry takes the printer's IP and stops at the
    attempt that armed the gate, instead of spending three more attempts
    and six seconds on connections that cannot happen. It also reports the
    attempts it really made -- "failed after 4 attempts" for one attempt is
    part of how this read as a network problem.

    Two diagnosis fixes go with it. The cool-off skip was the one connect()
    failure path that reported without naming its cause, and at DEBUG, so
    four identical reason-free warnings were all the operator saw. It now
    says at WARNING that nothing was sent and how long the printer has
    left, once per cool-off rather than once per attempt -- not every
    caller is gated, and a download-zip of 200 files would otherwise repeat
    the sentence 200 times, which is the flood #2780 set out to stop.

    And a dispatch that fails this way no longer tells anyone to check
    whether the SD card is inserted and formatted -- nothing reached the
    printer's filesystem, so the card is the one part of the machine that
    was working. The message names the file service and rules the card out.
    It is used only when a handshake failed during the dispatch itself,
    read from the cool-off deadline MOVING rather than merely being armed:
    the dispatch ignores the gate, so it can be running underneath one an
    unrelated background fetch left behind, and blaming TLS for an upload
    that really hit a full disk would repeat the mistake in the other
    direction.

    Tests count sockets rather than return values, since "returned False"
    looks identical whether or not anything was attempted -- which is what
    made the original report a log dive. Reverting any one of the five
    behaviours above fails a distinct test.
2026-08-23 12:43:00 +02:00
maziggy fa10414d0b Leave archived projects out of the pickers that file work (issue #2888)
The reporter opens a project per job and archives it when the job is
    done, so the Project dropdown in Edit Archive listed five live projects
    behind thirty-odd finished ones, in one unscrolled run with nothing to
    tell them apart.

    Archived is the state that means "put this away", so that is what the
    pickers now drop: the Edit Archive dropdown, the pending-uploads panel,
    the bulk Add to Project dialog and the File Manager's folder link.
    Completed stays. It says the work is finished, not that it should be
    hidden, and filing a reprint under a finished project is ordinary.

    The Archives right-click submenu had the opposite bug and offered
    active projects only, so a completed project was reachable from the
    edit dialog and not from the menu next to it. All five surfaces share
    one rule now.

    Whatever a thing is already filed under survives the filter whatever
    its status. A select holding a value that matches none of its options
    is reset by the browser to the first one, and here that reads "No
    project" -- an archive sitting in an archived project would have said
    in as many words that it was filed nowhere. The stored id does survive
    an untouched save; it is the field that lies.

    The parent-project picker is deliberately untouched. A finished or
    archived project is still a legal parent, and its own comment says so.

    Fixed alongside it, and reported separately: the status tabs counted
    only the projects the selected filter had already let through, so every
    tab but the current one counted zero and dropped its badge. Switching
    tabs moved the number rather than showing four of them. They are
    counted from the unfiltered list the page already fetches for the
    sub-project captions -- same query key, no extra request.

    The new rule is one function with its own unit tests, since five
    callers now depend on it reading the same way. Each half is pinned
    separately: dropping the filter, dropping the kept id, restoring
    active-only, and counting from the filtered list each fail their own
    tests and nothing else.
2026-08-23 12:42:41 +02:00