Commit Graph
4086 Commits
Author SHA1 Message Date
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
maziggy b3638e7fb2 Stop the G-code preview from sizing the box that sizes it (issue #2887)
Opening a 3D Preview from Archives left an empty white pane with the
    legend and the layer slider drawn over it, and the page's scrollbar
    shrank for as long as it stayed open -- about 190px of page height a
    second, with no limit.

    The viewer appends its canvas into the very element it measures with
    clientWidth/clientHeight and watches with a ResizeObserver. setSize
    writes each new size onto the canvas as inline style, and three.js
    leaves the canvas display:inline, so the line box adds descender space
    on top of the height just set. Where that element takes its height from
    its contents, the canvas sizes the box that sizes the canvas and gains a
    fixed 33px every round -- the reporter measured container = canvas + 33
    on every sample.

    The page gave it no height to take instead. The viewer pane is flex-1
    min-h-0, which divides nothing unless the column above it is a definite
    height, and h-full is a percentage resolved against a main area whose
    own height comes from a min-height -- a floor, not a size. So it fell
    through to the content, and the content was the canvas.

    Nothing was ever drawn because of the same loop, not a second fault:
    every observer callback reallocated and cleared the frame buffer, and an
    antialiased render of what had grown to roughly 18 megapixels never
    finished before the next one arrived. The data path was fine throughout,
    which the legend and the 1..57 layer slider both prove -- they are built
    from the parsed toolpath.

    The canvas is now positioned out of flow, so it cannot contribute to the
    height of the element that measures it on any page, and that element
    takes a definite height from the pane around it rather than a
    percentage. display:block goes on too, for the case where something
    overrides the positioning. The page is sized from the viewport the way
    the File Manager page already was.

    Either change alone stops the growth, but the structural one alone would
    trade it for a collapsed pane: an out-of-flow canvas contributes nothing
    to content height, so with no definite height above it the pane becomes
    clientHeight || 1. They belong together.

    The same viewer in the File Manager dialog was never affected -- a
    dialog gives it a fixed height, so neither fault could arise there.

    jsdom does no layout, so the loop cannot be reproduced in a test. The
    structure that forbids it can: the new cases assert the canvas is out of
    flow, that the measured element is definite-height rather than h-full,
    and that the pane stays positioned so inset-0 resolves against it.
    Reverting either change fails exactly those.
2026-08-23 12:42:16 +02:00
maziggy 38abf57bb7 Let a print start on a nozzle the printer can fetch (issue #2885)
The nozzle-diameter guard compared the sliced diameter against the
    mounted hotends alone. On an H2C both hotends commonly read the same
    size, so a job sliced for anything else was failed before upload with
    "install the matching nozzle before printing" -- even with that nozzle
    sitting in the tool-changer rack, which the printer fetches by itself
    as part of starting a print. The reporter saw it as an asymmetry:
    going to 0.4mm always worked, going to 0.2mm never did, and only
    fetching the nozzle by hand on the printer's own screen let the print
    run. It was never only about 0.2mm -- with a 0.6mm docked and 0.4mm
    hotends, a 0.6mm slice was refused identically.

    Placement is what made it fatal rather than merely wrong. The guard
    runs near the top of _start_print and the rack picker near the bottom,
    so the item was failed before the code that would have chosen the dock
    ever ran.

    Bambuddy had the rack contents the whole time. nozzle_info carries an
    entry per nozzle -- ids 0/1 for the hotends, 16-21 for the docks --
    each with its diameter, so the guard now tests the slice against both
    sets. It keys off the ids rather than the printer model: only a rack
    machine reports 16-21, which leaves no registry to keep in sync. An
    empty dock is absent from the payload entirely, so an id appearing
    there already means a nozzle is in it. stat is left uninterpreted; it
    read 0 on every entry, occupied and empty alike.

    A diameter in neither a hotend nor a dock still stops the print before
    it uploads, and the message now names both sets so the machine's real
    stock is visible.

    The same telemetry showed a second fault, pointing the other way. A
    hotend with nothing mounted is still reported, keeping the diameter of
    the nozzle it last held -- measured at idle, where the rack-side hotend
    read 0.4mm with max_temp 0 and serial "N/A" after parking its nozzle
    back in the dock. That stale value counted as installed. Presence now
    comes from the serial and the temperature rating, and emptiness has to
    be stated rather than merely unstated: the serial must be the
    firmware's explicit "N/A" and the rating absent. A firmware that
    reports neither field normalises to exactly that, and reading it as
    empty would switch the guard off on that machine.

    Confirmed on an H2C with 0.4mm hotends and a 0.2mm in R6: the job
    dispatches with nozzle_mapping [21], chosen by Bambuddy rather than
    left to the firmware.
2026-08-23 12:41:45 +02:00
maziggy 7b181b84f0 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 10:36:50 +02:00
maziggy 0f3063aa1a Point the Watchtower recommendation at the maintained fork (issue #2917) 2026-08-23 08:55:07 +02:00
maziggy 20bf108e55 Updated BACKERS 2026-08-22 14:25:53 +02:00
Maksim Sadontsev ed85677913 Light the generated thumbnails so one model differs from another (#2816) (#2861) 2026-08-22 14:16:56 +02:00
maziggy 68140747f2 Post work PR #2853 2026-08-22 13:43:05 +02:00
Sean K 55cc64c87d Add printer video downloads and range selection (#2853) 2026-08-22 13:22:56 +02:00
sgiffhorn 937440f956 Report the selected plate on the archives API (#2796) (#2871) 2026-08-22 12:44:47 +02:00
maziggy 4e40a5022c 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-22 12:26:18 +02:00
maziggy 88e8ca81c3 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-22 11:41:28 +02:00
maziggy cc39acfc74 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-22 10:50:22 +02:00
maziggy 70ee53464d 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-22 10:26:12 +02:00
maziggy 7c8f1f9435 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-22 10:05:05 +02:00
maziggy e4aab7b44b 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-22 09:27:26 +02:00
maziggy 3916db822c Stop the G-code preview from sizing the box that sizes it (issue #2887)
Opening a 3D Preview from Archives left an empty white pane with the
legend and the layer slider drawn over it, and the page's scrollbar
shrank for as long as it stayed open -- about 190px of page height a
second, with no limit.

The viewer appends its canvas into the very element it measures with
clientWidth/clientHeight and watches with a ResizeObserver. setSize
writes each new size onto the canvas as inline style, and three.js
leaves the canvas display:inline, so the line box adds descender space
on top of the height just set. Where that element takes its height from
its contents, the canvas sizes the box that sizes the canvas and gains a
fixed 33px every round -- the reporter measured container = canvas + 33
on every sample.

The page gave it no height to take instead. The viewer pane is flex-1
min-h-0, which divides nothing unless the column above it is a definite
height, and h-full is a percentage resolved against a main area whose
own height comes from a min-height -- a floor, not a size. So it fell
through to the content, and the content was the canvas.

Nothing was ever drawn because of the same loop, not a second fault:
every observer callback reallocated and cleared the frame buffer, and an
antialiased render of what had grown to roughly 18 megapixels never
finished before the next one arrived. The data path was fine throughout,
which the legend and the 1..57 layer slider both prove -- they are built
from the parsed toolpath.

The canvas is now positioned out of flow, so it cannot contribute to the
height of the element that measures it on any page, and that element
takes a definite height from the pane around it rather than a
percentage. display:block goes on too, for the case where something
overrides the positioning. The page is sized from the viewport the way
the File Manager page already was.

Either change alone stops the growth, but the structural one alone would
trade it for a collapsed pane: an out-of-flow canvas contributes nothing
to content height, so with no definite height above it the pane becomes
clientHeight || 1. They belong together.

The same viewer in the File Manager dialog was never affected -- a
dialog gives it a fixed height, so neither fault could arise there.

jsdom does no layout, so the loop cannot be reproduced in a test. The
structure that forbids it can: the new cases assert the canvas is out of
flow, that the measured element is definite-height rather than h-full,
and that the pane stays positioned so inset-0 resolves against it.
Reverting either change fails exactly those.
2026-08-22 09:05:12 +02:00
maziggy 4961990a7a Let a print start on a nozzle the printer can fetch (issue #2885)
The nozzle-diameter guard compared the sliced diameter against the
mounted hotends alone. On an H2C both hotends commonly read the same
size, so a job sliced for anything else was failed before upload with
"install the matching nozzle before printing" -- even with that nozzle
sitting in the tool-changer rack, which the printer fetches by itself
as part of starting a print. The reporter saw it as an asymmetry:
going to 0.4mm always worked, going to 0.2mm never did, and only
fetching the nozzle by hand on the printer's own screen let the print
run. It was never only about 0.2mm -- with a 0.6mm docked and 0.4mm
hotends, a 0.6mm slice was refused identically.

Placement is what made it fatal rather than merely wrong. The guard
runs near the top of _start_print and the rack picker near the bottom,
so the item was failed before the code that would have chosen the dock
ever ran.

Bambuddy had the rack contents the whole time. nozzle_info carries an
entry per nozzle -- ids 0/1 for the hotends, 16-21 for the docks --
each with its diameter, so the guard now tests the slice against both
sets. It keys off the ids rather than the printer model: only a rack
machine reports 16-21, which leaves no registry to keep in sync. An
empty dock is absent from the payload entirely, so an id appearing
there already means a nozzle is in it. stat is left uninterpreted; it
read 0 on every entry, occupied and empty alike.

A diameter in neither a hotend nor a dock still stops the print before
it uploads, and the message now names both sets so the machine's real
stock is visible.

The same telemetry showed a second fault, pointing the other way. A
hotend with nothing mounted is still reported, keeping the diameter of
the nozzle it last held -- measured at idle, where the rack-side hotend
read 0.4mm with max_temp 0 and serial "N/A" after parking its nozzle
back in the dock. That stale value counted as installed. Presence now
comes from the serial and the temperature rating, and emptiness has to
be stated rather than merely unstated: the serial must be the
firmware's explicit "N/A" and the rating absent. A firmware that
reports neither field normalises to exactly that, and reading it as
empty would switch the guard off on that machine.

Confirmed on an H2C with 0.4mm hotends and a 0.2mm in R6: the job
dispatches with nozzle_mapping [21], chosen by Bambuddy rather than
left to the firmware.
2026-08-22 08:46:07 +02:00
maziggy 6c76f6a133 Updated CHANGELOG 2026-08-19 09:39:31 +02:00
maziggy 1869ab0953 Updated CHANGELOG 2026-08-19 09:33:42 +02:00
maziggy f333642e09 Show K-profile value on AMS slot card (#2854) 2026-08-19 09:33:25 +02:00
maziggy 5925e387f0 Name an AMS slot's colour by its material, not its hex alone (issue #2875)
A hex is not one colour in Bambu's range. #FFFFFF is Jade White in PLA
    Basic, Ivory White in PLA Matte and plain White in six other materials;
    popover resolved its title from the hex alone, against a map that keeps
    one name per hex, so an ivory Matte spool read "Jade White" while the
    profile line beside it correctly read Matte Ivory.

    /inventory/colors/map now carries the names collapsing loses, keyed
    "<material>|<hex>". An entry is emitted only when it recovers a name the
    same manufacturer's own range lost -- 11 of them against the 608 colours
    in the shipped catalog. Both halves matter: a name equal to the flat
    answer is weight, and a name from another brand is not a recovery, it
    would put Prusament's "Pristine White" on every generic white PLA slot.

    A slot with a spool assigned from Inventory is titled with that spool's
    own colour name: it is the roll the user said is in there. Bambu
    internal codes are still rejected as non-names (#857).
2026-08-19 09:33:08 +02:00
maziggy a13e9f0d8e Stop waking printers that cannot print the queued job (issue #2876)
The queue's smart-plug step chose a printer to switch on by model alone.
    With a class-targeted job and every matching printer off, it walked the
    farm in printer-ID order, woke the first machine with an Auto On plug,
    and only then read the loaded filament -- so a job for a colour loaded at
    the far end of the farm woke every earlier printer in turn and left each
    one running until its own auto-power-off timer expired.

    The colours were known the whole time. A printer keeps its last reported
    AMS and external-spool trays after the power goes; mark_power_off blanks
    connected and state and leaves raw_data alone. The wake step now asks the
    same three questions the matcher asks a live printer -- required types,
    forced colours, preferred colours -- of that reading, and passes over a
    printer it rules out. A printer with no reading at all is still woken:
    never having heard is not the same as nothing being loaded.

    The matcher reports such a printer as needing filament rather than as
    offline, so the waiting reason explains why nothing was switched on.

    The manager now keeps a printer's last tray reading when it drops the
    client, and the queue falls back to that. _power_on_and_wait calls
    connect_printer in a retry loop, so an attempt that timed out used to
    erase the reading the next one depends on. The record is kept beside the
    clients, not inside one: the AMS merge is additive, so feeding it back
    into live status would merge an unplugged AMS unit back in for good.
2026-08-19 09:32:49 +02:00
maziggy aa6723c608 Take the layer height from the plate that actually printed
The archive card, the library file details and the slice dialog all read
    the layer height from a 3MF's project_settings.config. That records the
    project's settings and can still describe an earlier process or another
    plate; the plate's own G-code - what the printer executes - was never
    consulted for it, because the parser read the first 4KB, enough for the
    layer count in the header block but not for the config block that carries
    layer_height 14-25KB in. A print running at 0.08 on the H2C archived as
    0.2 with the layer count from the same file correct beside it.

    The plate G-code now wins wherever the two disagree, and the plate that
    was printed is the one read - the header parse used to take the first
    gcode entry in the zip regardless of which plate the archive was for.
    Source 3MFs, which carry no G-code, keep the project value as before.

    ---

    Stop carrying a file's layer height over the preset you picked

    Bambuddy carries a designer's process deviations across a re-slice
    (#2622) and pre-ticked every one that was not machine-coupled.
    layer_height is one MakerWorld projects routinely carry, so picking
    "0.08mm High Quality" for a file whose designer had moved layer height to
    0.2 sliced at 0.2 while the dropdown still read 0.08 - the same 0.2 the
    settings panel showed, tagged "from file".

    Layer height and first layer height are now classified preset_defining
    and treated like the machine-coupled keys: offered, never pre-selected.
    The flag travels on DesignOverride so the modal and the backend agree,
    and the panel's badge names the conflict and shows the preset's own value
    next to the file's, so ticking one is a deliberate choice.
2026-08-19 09:32:31 +02:00
maziggy dbbcf12619 Keep the printer's name on statistics after it is deleted (issue #2873)
Every per-printer breakdown resolved the name against the printers that
    exist now, so deleting a printer and choosing to keep its prints turned
    "Ultron" into "Printer 1" in Prints by Printer, the success-rate and
    time-accuracy lists, and Failures by Printer. Archives lose their printer
    on that delete as well, so nothing was left to read a name from.

    The runs themselves recorded the name they printed on. /archives/stats now
    reports the last name each id was known by - taken from the newest run that
    has one, so a later name-less row cannot blank it - and failure analysis
    falls back to the same thing for ids with no printer left. The client keeps
    preferring a live printer's own record, so a rename still shows up straight
    away rather than after the next print.
2026-08-19 09:32:13 +02:00
maziggy c8e794440f Restore the skip-objects list after a restart mid-print
The object list lives in PrinterState and is filled by the print-start path,
    which bambu_mqtt suppresses on the first RUNNING push after startup so a
    running print is not archived twice (#1304). Everything else that moment
    restores came back - the archive into _active_prints, the filament
    attribution session, the timelapse baseline - and the object list did not.
    So the card saw zero objects and greyed out its Skip button for the rest of
    the print. Measured on the maintainer's H2C: 8 objects loaded at 09:02, a
    restart at 09:17, Skip dead for the remaining hour.

    Nothing could bring it back either. GET /print/objects rebuilds the list
    whenever it is empty, but its only caller is the modal that the greyed-out
    button opens.

    on_print_running_observed now reloads the objects from the archive of the
    print that is still running, anchored on subtask_id - the firmware mints one
    per print, so a leftover status="printing" row from a completion that was
    never seen cannot lend its objects to another job. Without an id nothing is
    loaded rather than guessed; the endpoint's own reload covers that on demand.

    That endpoint now reads the archived 3MF from disk before it asks the
    printer. The archive of a running print normally holds the very file the
    printer is executing, so the fan-out was fetching back 15 MB Bambuddy
    already had, over the printer's single FTP socket, while it was printing -
    and on a printer that kept the file on internal storage it cannot succeed at
    all. FTP stays as the fallback. skipped_objects is left alone: a reload is
    not a new print, and what the user has already skipped only lives there.

    The plate image had the same fault one layer down. Opening the modal asks
    for the cover, the top view and the object-ID mask, and the in-memory 3MF
    cache those share dies with the process - so after a restart all three went
    back to the printer at once: three fan-outs, thirteen seconds, and a 0-byte
    read from socket contention, which is the storm #972 was about. The cover
    flow takes the running print's archived file too, resolved in the caller's
    short-lived session and passed in so _produce_cover_image still does no DB
    work, and marked as a shared file so the cleanup cannot delete the archive.

    Finally the card: a running print always has at least one object, so a count
    of zero means "not loaded", not "nothing to skip". Exactly one object is the
    real nothing-to-skip case and still disables the button.

    ---

    Stop a test's printer client leaking into the next test

    POST /api/v1/printers really connects, so a test that creates a printer
    through the API leaves a live client in the printer_manager singleton. The
    singleton outlives the per-test in-memory database, so the next test on that
    xdist worker - whose own first printer is handed the same primary key - reads
    that leftover client as its own live status.

    test_scheduled_drying_routes was the visible victim: an "online" printer with
    no firmware version fails the drying preflight, so scheduling came back 400
    instead of 200. It only bites when --dist load happens to put victim and
    leaker on one worker, which is why it passes on its own and flakes under -n.

    Registrations made during a test are now undone after it, ids the test did not
    add are left alone, and disconnect_printer is what also drops the model and
    printer-info caches and stops the paho thread the leaked client was keeping
    alive against an unreachable address for the rest of the run.
2026-08-19 09:31:43 +02:00
maziggy 7fa27f83e1 Let a print with no 3MF be given its filament weight (issue #1820)
When the sliced file stays somewhere Bambuddy cannot read, the archive is
    built from the printer's report alone and carries no weight. Nothing could
    supply one afterwards: rescan reads the figure out of the 3MF, and that
    archive has no file to read. The reporter's H2S print left 46.16 g on the
    spool with nothing recording it, and he corrected Spoolman by hand.

    Edit Archive now has a Filament used (g) field. It is written to the
    archive's most recent run as well, because the Projects roll-up and the
    Prometheus counter sum PrintLogEntry rather than the cards - correcting
    only the archive would fix the display and leave every aggregate reading
    the old figure, or none at all.

    But not over a figure the run measured for itself. A run's grams come from
    the tracked spool delta when there is one and only fall back to copying
    the archive's estimate when there is not, so mirroring unconditionally
    would overwrite a measurement with a typed estimate. The mirror now takes
    a run that has no figure, or one holding exactly what this archive held -
    which also makes the undo complete, since clearing the archive clears the
    copy it made and leaves a measured run alone.

    The field is text rather than a number input. A number input reports an
    empty string for anything the browser judges malformed, a decimal comma in
    a locale that does not expect one included, and that reads here as "the
    user cleared it" - it would have wiped a good figure while the field still
    showed what was typed. Filtering on the way in keeps what is displayed and
    what would be sent the same string, and clamps it to the range the API
    accepts: this modal has no error surface, so a refused save looks like
    nothing happened at all.

    Saving also invalidates the archive's runs query. The Print Log this modal
    renders at its top reads them separately and kept serving the pre-edit row,
    so a correction looked like it had not taken - true for the status and
    failure-reason mirrors since #1444 as well.

    Second half of the same report: the internal-storage probe (#2856) logged
    which file it found but not where. On a printer that keeps uploads for
    weeks - the reporter has months of them in /cache - a reprint of a name
    that was re-sliced but never re-sent can match an older copy, and without
    the directory that mismatch is invisible rather than merely rare. The
    download helper returns the path that served the file instead of a bare
    flag; every caller only ever tested it for truth.
2026-08-19 09:31:20 +02:00
maziggy 339682ee58 Keep card and row actions reachable without a hover-capable pointer (issue #2865)
Tailwind v4 compiles group-hover: inside @media (hover: hover) - the
    shipped CSS has .group-hover\:opacity-100 sitting in exactly that block.
    On a touch-only device the media query never matches, so the rule that
    reveals the control is not merely never triggered: it is never applied.
    A control written as opacity-0 group-hover:opacity-100 is invisible for
    good. The reporter's iPhone screenshot shows the project card with
    nothing where the "..." belongs, and Edit and Delete live only there.

    So the hiding half is what has to depend on the pointer, not the
    revealing half. A can-hover variant carries the query - the same one the
    history thumbnail preview has used since it was written - and the six
    controls behind it become can-hover:opacity-0 group-hover:opacity-100.
    Without a hover-capable pointer no rule hides them and they simply
    render; with one, nothing changes. Every reveal selector is specificity
    (0,2,0) against the hider's (0,1,0), so which one wins does not depend on
    where they land in the stylesheet.

    Six controls were affected: the project card menu, the File Manager's
    folder actions (reachable by accident today - the "wrap names" toggle
    drops the hover class), duplicate preset, rename and delete tag, delete
    print photo, delete plate reference.

    Six more already tried to handle this, by viewport width under 768px on
    the Archives cards and the File Manager's file cards. That covers a phone
    and misses an iPad in landscape, which is touch-only at 1024px. They move
    to the capability check and useIsMobile goes with them, along with the
    isMobile prop threaded into FileCard.

    opacity-0 also leaves a button focusable while invisible, so tabbing
    through a card stopped on a control nobody could see. Focus now reveals
    them, through group-focus-within on the wrappers and focus-visible on the
    standalone buttons - neither is hover-gated.

    jsdom does not evaluate media queries, so the tests pin the class
    contract instead: a bare opacity-0 is the defect, because it applies
    unconditionally while everything that undoes it does not.

    The decorative hover reveals are left alone - the archive hash badge, the
    project name overlay, the thumbnail preview, the swatch tooltips. Nothing
    is unreachable there, only unseen.
2026-08-19 09:30:55 +02:00
maziggy f3c6e8ff26 Release the plate-clear gate on a powered-down printer (issue #2864)
POST /printers/{id}/clear-plate answered 400 "Printer not connected" for
    anything without a live MQTT client, and the printer card hid the button
    under the same condition. With Auto Power Off that is the ordinary end of
    every print: the reporter's log has printer 1 marked offline at 12:00:55
    by the plug and the clear-plate POST rejected at 12:03:12, with the plate
    already cleared by hand. Nothing could release the gate short of powering
    each printer back on, clearing, and switching it off again.

    Nothing in the clear path talks to the printer. set_awaiting_plate_clear
    writes an in-memory set and the printers.awaiting_plate_clear column, and
    that column exists precisely so the gate survives an Auto Off cycle
    (#961). The guard came in with the endpoint in aa87e5598, copied from the
    stop/pause/resume handlers beside it, where reaching the printer is the
    whole point. The scheduler already reads the flag off a powered-off
    printer - it refuses to wake one that is still gated - and the wiki
    recommends the MQTT topic for automations because it does not depend on
    the printer being powered on. Only the write path disagreed.

    The card follows: showClearPlateButton drops the connection term, and the
    expanded-view button - which lived inside the block that renders nothing
    without a live status - is shared and given its own slot below it. The
    bulk filter tests clearPlate before the connection filter; every other
    bulk action still needs to reach the machine. The plate pill stays
    connected-only, since its only render site is inside that same block.

    Two things on the same path needed the flag without a client. GET /status
    returned the schema default for a printer with no cached state - manually
    disconnected, or not yet reconnected after a restart - reporting a clean
    plate the database disagreed with, and hiding the control on exactly the
    printers that needed it. And _emit_plate_clear_change bailed when the
    printer info cache was empty, which would have left the retained
    plate_clear topic (#2525) asserting "awaiting" after the gate was
    released; it now falls back to the row.

    This does not dispatch to an unreachable printer: _is_printer_idle still
    requires a connection. Releasing the gate is what lets the queue switch
    the printer on for the next job instead of passing it over.
2026-08-19 09:30:30 +02:00
maziggy de74914db9 Updated CHANGELOG 2026-08-19 09:24:29 +02:00
gyrene2083 8d1daab23a Show K-profile value on AMS slot card (#2854) 2026-08-19 09:22:30 +02:00
maziggy 3901043238 Name an AMS slot's colour by its material, not its hex alone (issue #2875)
A hex is not one colour in Bambu's range. #FFFFFF is Jade White in PLA
Basic, Ivory White in PLA Matte and plain White in six other materials;
popover resolved its title from the hex alone, against a map that keeps
one name per hex, so an ivory Matte spool read "Jade White" while the
profile line beside it correctly read Matte Ivory.

/inventory/colors/map now carries the names collapsing loses, keyed
"<material>|<hex>". An entry is emitted only when it recovers a name the
same manufacturer's own range lost -- 11 of them against the 608 colours
in the shipped catalog. Both halves matter: a name equal to the flat
answer is weight, and a name from another brand is not a recovery, it
would put Prusament's "Pristine White" on every generic white PLA slot.

A slot with a spool assigned from Inventory is titled with that spool's
own colour name: it is the roll the user said is in there. Bambu
internal codes are still rejected as non-names (#857).
2026-08-19 09:06:11 +02:00
maziggy dd50c51c1b Stop waking printers that cannot print the queued job (issue #2876)
The queue's smart-plug step chose a printer to switch on by model alone.
With a class-targeted job and every matching printer off, it walked the
farm in printer-ID order, woke the first machine with an Auto On plug,
and only then read the loaded filament -- so a job for a colour loaded at
the far end of the farm woke every earlier printer in turn and left each
one running until its own auto-power-off timer expired.

The colours were known the whole time. A printer keeps its last reported
AMS and external-spool trays after the power goes; mark_power_off blanks
connected and state and leaves raw_data alone. The wake step now asks the
same three questions the matcher asks a live printer -- required types,
forced colours, preferred colours -- of that reading, and passes over a
printer it rules out. A printer with no reading at all is still woken:
never having heard is not the same as nothing being loaded.

The matcher reports such a printer as needing filament rather than as
offline, so the waiting reason explains why nothing was switched on.

The manager now keeps a printer's last tray reading when it drops the
client, and the queue falls back to that. _power_on_and_wait calls
connect_printer in a retry loop, so an attempt that timed out used to
erase the reading the next one depends on. The record is kept beside the
clients, not inside one: the AMS merge is additive, so feeding it back
into live status would merge an unplugged AMS unit back in for good.
2026-08-19 08:26:43 +02:00
maziggy 7e77bf5833 Take the layer height from the plate that actually printed
The archive card, the library file details and the slice dialog all read
the layer height from a 3MF's project_settings.config. That records the
project's settings and can still describe an earlier process or another
plate; the plate's own G-code - what the printer executes - was never
consulted for it, because the parser read the first 4KB, enough for the
layer count in the header block but not for the config block that carries
layer_height 14-25KB in. A print running at 0.08 on the H2C archived as
0.2 with the layer count from the same file correct beside it.

The plate G-code now wins wherever the two disagree, and the plate that
was printed is the one read - the header parse used to take the first
gcode entry in the zip regardless of which plate the archive was for.
Source 3MFs, which carry no G-code, keep the project value as before.

---

Stop carrying a file's layer height over the preset you picked

Bambuddy carries a designer's process deviations across a re-slice
(#2622) and pre-ticked every one that was not machine-coupled.
layer_height is one MakerWorld projects routinely carry, so picking
"0.08mm High Quality" for a file whose designer had moved layer height to
0.2 sliced at 0.2 while the dropdown still read 0.08 - the same 0.2 the
settings panel showed, tagged "from file".

Layer height and first layer height are now classified preset_defining
and treated like the machine-coupled keys: offered, never pre-selected.
The flag travels on DesignOverride so the modal and the backend agree,
and the panel's badge names the conflict and shows the preset's own value
next to the file's, so ticking one is a deliberate choice.
2026-08-18 16:23:32 +02:00
maziggy 28781ea558 Keep the printer's name on statistics after it is deleted (issue #2873)
Every per-printer breakdown resolved the name against the printers that
exist now, so deleting a printer and choosing to keep its prints turned
"Ultron" into "Printer 1" in Prints by Printer, the success-rate and
time-accuracy lists, and Failures by Printer. Archives lose their printer
on that delete as well, so nothing was left to read a name from.

The runs themselves recorded the name they printed on. /archives/stats now
reports the last name each id was known by - taken from the newest run that
has one, so a later name-less row cannot blank it - and failure analysis
falls back to the same thing for ids with no printer left. The client keeps
preferring a live printer's own record, so a rename still shows up straight
away rather than after the next print.
2026-08-18 14:04:18 +02:00
maziggy cfecfa360e Restore the skip-objects list after a restart mid-print
The object list lives in PrinterState and is filled by the print-start path,
which bambu_mqtt suppresses on the first RUNNING push after startup so a
running print is not archived twice (#1304). Everything else that moment
restores came back - the archive into _active_prints, the filament
attribution session, the timelapse baseline - and the object list did not.
So the card saw zero objects and greyed out its Skip button for the rest of
the print. Measured on the maintainer's H2C: 8 objects loaded at 09:02, a
restart at 09:17, Skip dead for the remaining hour.

Nothing could bring it back either. GET /print/objects rebuilds the list
whenever it is empty, but its only caller is the modal that the greyed-out
button opens.

on_print_running_observed now reloads the objects from the archive of the
print that is still running, anchored on subtask_id - the firmware mints one
per print, so a leftover status="printing" row from a completion that was
never seen cannot lend its objects to another job. Without an id nothing is
loaded rather than guessed; the endpoint's own reload covers that on demand.

That endpoint now reads the archived 3MF from disk before it asks the
printer. The archive of a running print normally holds the very file the
printer is executing, so the fan-out was fetching back 15 MB Bambuddy
already had, over the printer's single FTP socket, while it was printing -
and on a printer that kept the file on internal storage it cannot succeed at
all. FTP stays as the fallback. skipped_objects is left alone: a reload is
not a new print, and what the user has already skipped only lives there.

The plate image had the same fault one layer down. Opening the modal asks
for the cover, the top view and the object-ID mask, and the in-memory 3MF
cache those share dies with the process - so after a restart all three went
back to the printer at once: three fan-outs, thirteen seconds, and a 0-byte
read from socket contention, which is the storm #972 was about. The cover
flow takes the running print's archived file too, resolved in the caller's
short-lived session and passed in so _produce_cover_image still does no DB
work, and marked as a shared file so the cleanup cannot delete the archive.

Finally the card: a running print always has at least one object, so a count
of zero means "not loaded", not "nothing to skip". Exactly one object is the
real nothing-to-skip case and still disables the button.

---

Stop a test's printer client leaking into the next test

POST /api/v1/printers really connects, so a test that creates a printer
through the API leaves a live client in the printer_manager singleton. The
singleton outlives the per-test in-memory database, so the next test on that
xdist worker - whose own first printer is handed the same primary key - reads
that leftover client as its own live status.

test_scheduled_drying_routes was the visible victim: an "online" printer with
no firmware version fails the drying preflight, so scheduling came back 400
instead of 200. It only bites when --dist load happens to put victim and
leaker on one worker, which is why it passes on its own and flakes under -n.

Registrations made during a test are now undone after it, ids the test did not
add are left alone, and disconnect_printer is what also drops the model and
printer-info caches and stops the paho thread the leaked client was keeping
alive against an unreachable address for the rest of the run.
2026-08-18 13:43:46 +02:00
maziggy d227d42272 Let a print with no 3MF be given its filament weight (issue #1820)
When the sliced file stays somewhere Bambuddy cannot read, the archive is
built from the printer's report alone and carries no weight. Nothing could
supply one afterwards: rescan reads the figure out of the 3MF, and that
archive has no file to read. The reporter's H2S print left 46.16 g on the
spool with nothing recording it, and he corrected Spoolman by hand.

Edit Archive now has a Filament used (g) field. It is written to the
archive's most recent run as well, because the Projects roll-up and the
Prometheus counter sum PrintLogEntry rather than the cards - correcting
only the archive would fix the display and leave every aggregate reading
the old figure, or none at all.

But not over a figure the run measured for itself. A run's grams come from
the tracked spool delta when there is one and only fall back to copying
the archive's estimate when there is not, so mirroring unconditionally
would overwrite a measurement with a typed estimate. The mirror now takes
a run that has no figure, or one holding exactly what this archive held -
which also makes the undo complete, since clearing the archive clears the
copy it made and leaves a measured run alone.

The field is text rather than a number input. A number input reports an
empty string for anything the browser judges malformed, a decimal comma in
a locale that does not expect one included, and that reads here as "the
user cleared it" - it would have wiped a good figure while the field still
showed what was typed. Filtering on the way in keeps what is displayed and
what would be sent the same string, and clamps it to the range the API
accepts: this modal has no error surface, so a refused save looks like
nothing happened at all.

Saving also invalidates the archive's runs query. The Print Log this modal
renders at its top reads them separately and kept serving the pre-edit row,
so a correction looked like it had not taken - true for the status and
failure-reason mirrors since #1444 as well.

Second half of the same report: the internal-storage probe (#2856) logged
which file it found but not where. On a printer that keeps uploads for
weeks - the reporter has months of them in /cache - a reprint of a name
that was re-sliced but never re-sent can match an older copy, and without
the directory that mismatch is invisible rather than merely rare. The
download helper returns the path that served the file instead of a bare
flag; every caller only ever tested it for truth.
2026-08-18 10:36:42 +02:00
maziggy 90fac7b529 Keep card and row actions reachable without a hover-capable pointer (issue #2865)
Tailwind v4 compiles group-hover: inside @media (hover: hover) - the
shipped CSS has .group-hover\:opacity-100 sitting in exactly that block.
On a touch-only device the media query never matches, so the rule that
reveals the control is not merely never triggered: it is never applied.
A control written as opacity-0 group-hover:opacity-100 is invisible for
good. The reporter's iPhone screenshot shows the project card with
nothing where the "..." belongs, and Edit and Delete live only there.

So the hiding half is what has to depend on the pointer, not the
revealing half. A can-hover variant carries the query - the same one the
history thumbnail preview has used since it was written - and the six
controls behind it become can-hover:opacity-0 group-hover:opacity-100.
Without a hover-capable pointer no rule hides them and they simply
render; with one, nothing changes. Every reveal selector is specificity
(0,2,0) against the hider's (0,1,0), so which one wins does not depend on
where they land in the stylesheet.

Six controls were affected: the project card menu, the File Manager's
folder actions (reachable by accident today - the "wrap names" toggle
drops the hover class), duplicate preset, rename and delete tag, delete
print photo, delete plate reference.

Six more already tried to handle this, by viewport width under 768px on
the Archives cards and the File Manager's file cards. That covers a phone
and misses an iPad in landscape, which is touch-only at 1024px. They move
to the capability check and useIsMobile goes with them, along with the
isMobile prop threaded into FileCard.

opacity-0 also leaves a button focusable while invisible, so tabbing
through a card stopped on a control nobody could see. Focus now reveals
them, through group-focus-within on the wrappers and focus-visible on the
standalone buttons - neither is hover-gated.

jsdom does not evaluate media queries, so the tests pin the class
contract instead: a bare opacity-0 is the defect, because it applies
unconditionally while everything that undoes it does not.

The decorative hover reveals are left alone - the archive hash badge, the
project name overlay, the thumbnail preview, the swatch tooltips. Nothing
is unreachable there, only unseen.
2026-08-18 09:53:46 +02:00