Commit Graph
1757 Commits
Author SHA1 Message Date
maziggy 190d4f2ce8 Add temperatures to the streaming overlay and a URL builder (#1422)
The overlay at /overlay/{printer} draws live print data over a
    full-screen camera view for OBS, a wall display or any browser source.
    It has been tunable since it shipped -- which fields, what size, what
    frame rate -- but only through query parameters documented in the wiki,
    and temperatures were not among the fields on offer. The request asked
    for temperatures first and for the field set to be selectable in the web
    UI; this addresses both.

    Nozzle, bed and chamber readings join the list. The target is drawn only
    while the heater is still climbing, so a settled hotend reads "220°C"
    for the rest of the print instead of the noisier "220 / 220°C" -- 219.6
    against a target of 220 rounds to the same number, and repeating it says
    nothing. Both nozzles appear on a dual-nozzle machine. They are drawn
    whether or not a print is running, because a preheating printer is
    exactly when they are worth watching, and each reading appears only when
    the printer genuinely reports one: chamber temperature stays absent on
    P1 and A1 models, which publish a chamber_temper with no sensor behind
    it, so the overlay never puts a measurement on screen that does not
    exist. Labels reuse the heater chart's strings rather than inventing a
    second vocabulary for the same three things.

    The feed sends an allow-list rather than the temperatures dict. That
    dict doubles as the MQTT client's working memory -- derived heater flags
    and private target-set timestamps live alongside the readings -- and an
    overlay token is a narrower grant than a login, so it gets exactly what
    the overlay draws and does not pick up fields as the dict grows. The
    same chamber-sensor gate the full status payload already applies is
    applied here. The integration test that asserts the payload's exact key
    set, which exists to catch that surface widening silently, is updated
    deliberately.

    Temperatures are not in the default field set, so an overlay URL already
    pasted into a scene renders identically after upgrading.

    Settings -> API Keys -> Streaming Overlay now builds the URL: printer,
    field checkboxes, size, frame rate, camera toggle, an optional token,
    and a copy button. It persists nothing and calls nothing new -- the URL
    is the configuration, which keeps a scene reproducible by copy-paste and
    lets two displays show different fields off one token. Fields are
    emitted in the overlay's own top-to-bottom order rather than click
    order, and parameters left at their default are omitted, so the same
    selection always produces the same URL. The preview alongside it stays
    off until asked for: an always-live iframe would hold a subscriber on
    the printer's single camera connection for as long as the settings tab
    stayed open.

    The preview needed one narrow security-header change. Every SPA route
    sent frame-ancestors 'none', which is stricter than the SAMEORIGIN in
    X-Frame-Options beside it and refuses even a same-origin frame, so the
    preview showed Firefox's "another site has embedded it" page instead of
    the overlay. The overlay path now sends 'self', mirroring /gcode-viewer,
    which admits a framer only on this origin -- Bambuddy's own UI. Every
    other path keeps 'none', and embedding the overlay from another host
    still requires TRUSTED_FRAME_ORIGINS.
2026-08-15 14:07:27 +02:00
maziggy 268940573d Stop a drying cycle reporting itself finished a minute in (#2759)
Starting the dryer on an AMS 2 Pro holding two PETG and two PLA spools
    and picking PLA showed "PLA @ 45°C" for about a minute and then switched
    to "PETG @ 65°C" for the remaining twelve hours.

    Bambu never echoes back which filament or temperature a cycle is
    running, so the badge reads the target cached when the command went out,
    and that cache had been dropped. Between accepting the command and
    settling its countdown the firmware publishes one update with the
    remaining time at zero while the unit is still in its Checking phase --
    the reporter's log has 720, then 0, then 719, and four seconds later the
    same unit's info hex decodes to dry_status 2, Drying. The falling-edge
    detector read that zero as the cycle ending. Losing the cached target
    left the badge to guess from the first loaded slot, which was PETG, and
    its RFID-recommended 65°C. The same false ending fired
    on_drying_complete, so anyone with smart-plug auto-off-after-drying
    switched on had power scheduled to cut one minute into a twelve-hour
    dry; the reporter had it off, which is the only reason this reads as a
    cosmetic bug.

    A remaining time of zero now ends a cycle only when the unit is not also
    reporting an active phase. dry_status comes from the same info hex
    already parsed a few lines above, so this costs nothing to check.
    Stopping and Error are deliberately not treated as active -- those
    should end it -- and a unit that reports no phase at all still ends its
    cycles, so the gate can only ever suppress on positive evidence that the
    cycle is live. A suppressed edge leaves the remembered dry_time alone,
    exactly as the #1462 absent-value skip does, so the push that really
    ends the cycle still sees a non-zero previous.

    The fallback guess is tightened to match. On a mixed unit the first tray
    is evidence of nothing, and naming a temperature the cycle is not using
    is worse than naming none, so it now answers only when every loaded
    spool is the same filament and otherwise leaves the badge showing the
    countdown alone. Both the websocket and REST status builders carried
    their own copy of that loop; they now share one helper, which also takes
    the temperature from the first slot that carries an RFID one rather than
    giving up when slot 1 holds a third-party spool.
2026-08-15 14:07:01 +02:00
maziggy 0d8d67c866 Say when AMS drying was running and a print never started (#2758)
Dispatching to an X2D with two AMS units mid-drying failed silently: the
    file uploaded, the printer accepted it and stayed idle. The watchdog waits
    for an active state or HMS_MQTT_VERIFY_FAILED, and a drying refusal is
    neither, so it timed out, re-uploaded the whole 3MF twice more, and closed
    with advice about the printer screen and the SD card. Studio, asked
    directly, said it could not start the job because of the drying.

    Latch the AMS dry_time telemetry across both watchdog phases and name the
    units in the give-up message, plus an INFO log on every failed window so
    the correlation reaches a support bundle from the first attempt.

    Detection only, no gate. These models support drying CONTINUING through a
    print (supports_drying_while_printing covers X2D from 01.01.00.00), so
    drying is not incompatible with printing and stopping it before every
    dispatch would tear down cycles the hardware is happy to run. One of the
    two units was also drying without its external PSU, which would make this a
    power budget problem at start-of-print calibration rather than a drying one
    -- dry_sf_reason 1/8 exist for exactly that. The message names both
    possibilities rather than asserting one.

    Also correct _sync_drying_state's docstring, which claimed to adopt drying
    it did not start; it only prunes. Behaviour unchanged -- populating it would
    let the scheduler stop a cycle the user started by hand.
2026-08-15 14:06:29 +02:00
maziggy 1136ce33ab Add CAP_NET_BIND_SERVICE everywhere the service is defined (#2549)
The Virtual Printer binds 990 and 322, below 1024, which a service running
    as a normal user may not do without CAP_NET_BIND_SERVICE. Without it the
    rest of Bambuddy works and only the VP is dead -- sockets never open, the
    slicer never finds the printer, and the sole trace is one journal line.

    332a7c6ac added the line to install/install.sh in March under the heading
    "Fix install.sh missing AmbientCapabilities". Three other places define the
    same unit and none of them got it: the manual template, the combined
    Bambuddy + SpoolBuddy installer, and the unit the wiki tells you to paste.
    The wiki additionally claimed the capability was always included.

    Also diagnose it. The VP diagnostic reported only that nothing was listening
    on 990, which reads identically to a port conflict. It now checks CapEff for
    the capability and names it as the cause -- but stays quiet when the port is
    answering (an iptables REDIRECT is the documented alternative and that host
    works) and when the capability is held (the port is down for another reason
    and blaming this would misdirect). Skips where there is no procfs rather
    than putting a systemd instruction in front of a macOS user.
2026-08-15 14:06:08 +02:00
maziggy 19fb43752c Report a refused AMS filament setting instead of discarding it (#2756)
Configuring a slot publishes ams_filament_setting and the printer answers
    with a verdict. The answer was received and dropped at DEBUG, so a refusal
    left no trace at the level support bundles are collected at: the reporter
    saw six Configure Slot attempts on an X1C all return success, all read back
    by the #2582 verification as holding the previous profile, and no record of
    what the printer said about any of them.

    Promote a non-success response to INFO with result, reason, ams_id and
    tray_id. Refusals only -- unlike extrusion_cali_set (#2718) and
    ams_filament_drying (#1447) this command is not rare, since every spool
    assignment and K-profile re-apply sends one, so promoting each ack would
    bury the interesting line.

    The developer-mode probe is excluded: it sends this same command to the
    external slot expecting a refusal on P1 firmware, so promoting it would
    put an alarming line in every P1 bundle on every reconnect. Matched by
    sequence id, which user commands cannot collide with -- they publish a
    hardcoded "0".

    Diagnostics only; no change to which commands are sent or how they are built.
2026-08-15 14:05:45 +02:00
maziggy f18a4b787e Show the compose directory in the Docker update command (#2664)
The printed command only works from the directory holding the compose
    file, which is the thing the user came to the page not knowing. Adds a
    copy button, a saved Compose directory setting, BAMBUDDY_COMPOSE_DIR,
    and best-effort detection from a bind mount's host path.

    Compose records the directory on every container it creates, but reading
    that label needs the Docker socket mounted in — root-equivalent access
    for a convenience string. The mountinfo guess is a prefill only: its root
    field is relative to the mounted device, so a compose dir on its own
    mount loses that prefix, and nothing in the container can detect it.

    The field is restricted to path characters. It is the one setting whose
    purpose is to be pasted into a root shell, so "/opt/bambuddy; rm -rf /"
    would otherwise render as a plausible update command.
2026-08-15 14:03:44 +02:00
maziggy 914cbffdcd Show the Print Log's per-run cost and energy, and let users pick columns (#2636)
The list and update endpoints serialised field by field and never named
    cost / energy_kwh / energy_cost, so values Bambuddy had been recording
    all along went out as nulls. Both now validate from the ORM row, which
    removes the chance to omit a field rather than patching the three that
    were missing.

    Adds a Filament Used column plus a Columns picker for Cost, Energy,
    Energy Cost and Finished, persisted per browser.

    Also fixes the log view being unreachable with zero archives: the empty
    state ran before the view check, hiding a log that outlives the archives
    it refers to.

    ---

    Sort the Print Log by any column (#2636)

    Adds sort_by / sort_dir to the print-log endpoint, driven by clickable
    column headers. Server-side because paging is: ordering the rows the
    client holds would sort one page rather than the log.

    Empty values are held last in both directions — Postgres sorts NULLs
    high and SQLite low, so the same click would otherwise open on blanks
    on one backend and values on the other. id DESC breaks ties so paging
    through a low-cardinality sort can't repeat or skip a row.
2026-08-15 14:03:18 +02:00
maziggy 67e8a78cb8 Add auto-orient and auto-arrange to server-side slicing (#2548)
Both are per-slice checkboxes, off by default, forwarded as the sidecar's
    orient / arrange form fields. An unticked box is sent by omission: the
    sidecar treats any present value as truthy, so a literal "false" would
    have arranged every slice.

    Arrange unions with the #1493 cross-class decision rather than replacing
    it, and the per-plate slice-all loop is now keyed on the arrange flag
    itself — the project-wide collapse belongs to --arrange, not to the
    cross-class case. The loop also covers the embedded-settings path, whose
    crash-retry is suppressed there since a single --slice 0 retry would
    return one consolidated plate.
2026-08-15 14:02:31 +02:00
maziggy 06d898f0a9 Document prefer_filename_for_name in the OpenAPI schema
The param was a bare bool, so /docs showed an undocumented boolean on
    both upload routes. #2609 is about external integrations, and the
    interactive docs are where those callers look — a docstring only reaches
    someone reading the source. Wraps both in Query(False, description=...),
    matching how this file documents its other query params.

    Also records why these two routes take the flag per-request while the FTP
    review flow and virtual-printer dispatch derive it from the VP-scoped
    virtual_printer_archive_name_source setting, and drops the db_session
    fixture the four new tests requested but never used.
2026-08-15 14:01:14 +02:00
maziggy 7ecd190f8b Raise the chamber-temperature ceiling from 60 to 65 C
Every field that takes a chamber target stopped at 60: the per-filament
    chamber map and per-print override in Preheat & Heat Soak, the chamber
    quick-select presets, and the printer-card chamber control. 60 is the
    X1E's ceiling and the X1E was the only heated-chamber model when that
    limit was written; the H2 series and X2D heat to 65, so the top of their
    range was unreachable.

    The ceiling now lives in one constant per side (MAX_CHAMBER_TEMP_C in
    backend/app/utils/printer_models.py and frontend/src/utils/printer.ts)
    rather than as a literal at each call site. X1E firmware clamps a higher
    request to its own maximum, so a shared ceiling is safe.

    Also fixes a live bug at PrintersPage.tsx:7985: parsePresetTriple was
    bounded to 60 there, and it rejects the whole triple on any out-of-range
    entry, so a saved 65 preset would have silently reverted the printer
    card to the defaults while Settings still showed 65.
2026-08-15 14:00:43 +02:00
maziggy a8378b0e0e Merge remote-tracking branch 'upstream/dev' into feature/upload-prefer-filename-for-name 2026-08-15 14:00:15 +02:00
maziggy bd6ad1a338 feat: expose prefer_filename_for_name on bulk archive upload too
Maintainer review on #2610 flagged that upload-bulk would diverge from
    upload if only the single-file route got the flag. Applies to every
    file in the batch, same default-off behavior.
2026-08-15 13:59:07 +02:00
maziggy 33ab5f1ead Add temperatures to the streaming overlay and a URL builder (#1422)
The overlay at /overlay/{printer} draws live print data over a
full-screen camera view for OBS, a wall display or any browser source.
It has been tunable since it shipped -- which fields, what size, what
frame rate -- but only through query parameters documented in the wiki,
and temperatures were not among the fields on offer. The request asked
for temperatures first and for the field set to be selectable in the web
UI; this addresses both.

Nozzle, bed and chamber readings join the list. The target is drawn only
while the heater is still climbing, so a settled hotend reads "220°C"
for the rest of the print instead of the noisier "220 / 220°C" -- 219.6
against a target of 220 rounds to the same number, and repeating it says
nothing. Both nozzles appear on a dual-nozzle machine. They are drawn
whether or not a print is running, because a preheating printer is
exactly when they are worth watching, and each reading appears only when
the printer genuinely reports one: chamber temperature stays absent on
P1 and A1 models, which publish a chamber_temper with no sensor behind
it, so the overlay never puts a measurement on screen that does not
exist. Labels reuse the heater chart's strings rather than inventing a
second vocabulary for the same three things.

The feed sends an allow-list rather than the temperatures dict. That
dict doubles as the MQTT client's working memory -- derived heater flags
and private target-set timestamps live alongside the readings -- and an
overlay token is a narrower grant than a login, so it gets exactly what
the overlay draws and does not pick up fields as the dict grows. The
same chamber-sensor gate the full status payload already applies is
applied here. The integration test that asserts the payload's exact key
set, which exists to catch that surface widening silently, is updated
deliberately.

Temperatures are not in the default field set, so an overlay URL already
pasted into a scene renders identically after upgrading.

Settings -> API Keys -> Streaming Overlay now builds the URL: printer,
field checkboxes, size, frame rate, camera toggle, an optional token,
and a copy button. It persists nothing and calls nothing new -- the URL
is the configuration, which keeps a scene reproducible by copy-paste and
lets two displays show different fields off one token. Fields are
emitted in the overlay's own top-to-bottom order rather than click
order, and parameters left at their default are omitted, so the same
selection always produces the same URL. The preview alongside it stays
off until asked for: an always-live iframe would hold a subscriber on
the printer's single camera connection for as long as the settings tab
stayed open.

The preview needed one narrow security-header change. Every SPA route
sent frame-ancestors 'none', which is stricter than the SAMEORIGIN in
X-Frame-Options beside it and refuses even a same-origin frame, so the
preview showed Firefox's "another site has embedded it" page instead of
the overlay. The overlay path now sends 'self', mirroring /gcode-viewer,
which admits a framer only on this origin -- Bambuddy's own UI. Every
other path keeps 'none', and embedding the overlay from another host
still requires TRUSTED_FRAME_ORIGINS.
2026-08-04 12:38:36 +02:00
maziggy 48c231d8ce Stop a drying cycle reporting itself finished a minute in (#2759)
Starting the dryer on an AMS 2 Pro holding two PETG and two PLA spools
and picking PLA showed "PLA @ 45°C" for about a minute and then switched
to "PETG @ 65°C" for the remaining twelve hours.

Bambu never echoes back which filament or temperature a cycle is
running, so the badge reads the target cached when the command went out,
and that cache had been dropped. Between accepting the command and
settling its countdown the firmware publishes one update with the
remaining time at zero while the unit is still in its Checking phase --
the reporter's log has 720, then 0, then 719, and four seconds later the
same unit's info hex decodes to dry_status 2, Drying. The falling-edge
detector read that zero as the cycle ending. Losing the cached target
left the badge to guess from the first loaded slot, which was PETG, and
its RFID-recommended 65°C. The same false ending fired
on_drying_complete, so anyone with smart-plug auto-off-after-drying
switched on had power scheduled to cut one minute into a twelve-hour
dry; the reporter had it off, which is the only reason this reads as a
cosmetic bug.

A remaining time of zero now ends a cycle only when the unit is not also
reporting an active phase. dry_status comes from the same info hex
already parsed a few lines above, so this costs nothing to check.
Stopping and Error are deliberately not treated as active -- those
should end it -- and a unit that reports no phase at all still ends its
cycles, so the gate can only ever suppress on positive evidence that the
cycle is live. A suppressed edge leaves the remembered dry_time alone,
exactly as the #1462 absent-value skip does, so the push that really
ends the cycle still sees a non-zero previous.

The fallback guess is tightened to match. On a mixed unit the first tray
is evidence of nothing, and naming a temperature the cycle is not using
is worse than naming none, so it now answers only when every loaded
spool is the same filament and otherwise leaves the badge showing the
countdown alone. Both the websocket and REST status builders carried
their own copy of that loop; they now share one helper, which also takes
the temperature from the first slot that carries an RFID one rather than
giving up when slot 1 holds a third-party spool.
2026-08-04 11:47:17 +02:00
maziggy 71a06f3638 Add batch orders with a quantity per plate (#342)
Printing a multi-plate file in different quantities per plate meant
queueing each plate separately and tracking the counts by hand: one
shared Quantity field cannot say "plate 1 once, plate 2 twice, plate 3
three times". Each selected plate now carries its own quantity, and the
submission becomes an order on a new Batches tab.

The point is the distinction the old flat batch could not express.
print_batch_plates stores how many runs of each plate were wanted,
separately from what was queued, so a run that fails, is cancelled or is
skipped does not satisfy a target -- the order goes on saying it owes a
print instead of quietly under-delivering. Queue remaining re-queues
exactly what is missing, for the whole order or one plate, by cloning
the most recent item for that plate: that inherits the printer or model
target, AMS mapping, filament overrides and print options along with the
validation they already passed, rather than re-serialising twenty fields
through a template that would drift from the model the first time
someone adds a column. Clones append to the end of the relevant
printer's queue and take the same advisory lock the add-to-queue route
does; positions are per-printer sequences, not global.

Cost is measured, not estimated. print_log_entries gains queue_item_id,
set where the queue item is already in scope, so each run's material and
energy are attributed through the item that produced them -- an
unrelated reprint of the same archive never lands in an order's total,
and a multi-plate order gets each plate's own cost rather than the whole
file's via the plate-scoped estimate from #2614. Before any run has
completed there is no honest figure, so cost reads as unknown instead of
a fabricated 0.00.

The Batches tab wires up GET /queue/batches, which has been unreferenced
since the batch MVP shipped, along with six locale keys that were
translated and never used. It is a separate tab because an order
outlives the queue that produced it: once its runs finish they leave the
active queue, so Queue and History each hold half the picture.

completed was not a reachable status before now, so every batch created
since April is still marked active however long ago its last print
finished -- 73 of them on the development install. A startup pass closes
out the finished ones: those whose runs all completed become completed,
and groupings whose items were all cancelled become cancelled, which is
what they are. Not applied to orders, which state their intent
independently of their runs and still owe the work. Only batches with
nothing queued or printing are considered, and repeating the pass also
catches an order whose last run landed while the process was down.
Batches with neither items nor targets are no longer listed at all --
empty shells left when a grouping's items went with their source
archive.

Dispatch applies the same source-file gates as POST /queue/. It creates
queue items, so without them it would be a weaker door to the same
outcome; the archive and library-file checks move into shared helpers
so a third route cannot drift from them.
2026-08-04 11:11:36 +02:00
maziggy ebc73e46ba Say when AMS drying was running and a print never started (#2758)
Dispatching to an X2D with two AMS units mid-drying failed silently: the
file uploaded, the printer accepted it and stayed idle. The watchdog waits
for an active state or HMS_MQTT_VERIFY_FAILED, and a drying refusal is
neither, so it timed out, re-uploaded the whole 3MF twice more, and closed
with advice about the printer screen and the SD card. Studio, asked
directly, said it could not start the job because of the drying.

Latch the AMS dry_time telemetry across both watchdog phases and name the
units in the give-up message, plus an INFO log on every failed window so
the correlation reaches a support bundle from the first attempt.

Detection only, no gate. These models support drying CONTINUING through a
print (supports_drying_while_printing covers X2D from 01.01.00.00), so
drying is not incompatible with printing and stopping it before every
dispatch would tear down cycles the hardware is happy to run. One of the
two units was also drying without its external PSU, which would make this a
power budget problem at start-of-print calibration rather than a drying one
-- dry_sf_reason 1/8 exist for exactly that. The message names both
possibilities rather than asserting one.

Also correct _sync_drying_state's docstring, which claimed to adopt drying
it did not start; it only prunes. Behaviour unchanged -- populating it would
let the scheduler stop a cycle the user started by hand.
2026-08-04 08:49:49 +02:00
maziggy 28a6ca6f4d Add CAP_NET_BIND_SERVICE everywhere the service is defined (#2549)
The Virtual Printer binds 990 and 322, below 1024, which a service running
as a normal user may not do without CAP_NET_BIND_SERVICE. Without it the
rest of Bambuddy works and only the VP is dead -- sockets never open, the
slicer never finds the printer, and the sole trace is one journal line.

332a7c6ac added the line to install/install.sh in March under the heading
"Fix install.sh missing AmbientCapabilities". Three other places define the
same unit and none of them got it: the manual template, the combined
Bambuddy + SpoolBuddy installer, and the unit the wiki tells you to paste.
The wiki additionally claimed the capability was always included.

Also diagnose it. The VP diagnostic reported only that nothing was listening
on 990, which reads identically to a port conflict. It now checks CapEff for
the capability and names it as the cause -- but stays quiet when the port is
answering (an iptables REDIRECT is the documented alternative and that host
works) and when the capability is held (the port is down for another reason
and blaming this would misdirect). Skips where there is no procfs rather
than putting a systemd instruction in front of a macOS user.
2026-08-04 08:33:30 +02:00
maziggy 4af782cc2f Report a refused AMS filament setting instead of discarding it (#2756)
Configuring a slot publishes ams_filament_setting and the printer answers
with a verdict. The answer was received and dropped at DEBUG, so a refusal
left no trace at the level support bundles are collected at: the reporter
saw six Configure Slot attempts on an X1C all return success, all read back
by the #2582 verification as holding the previous profile, and no record of
what the printer said about any of them.

Promote a non-success response to INFO with result, reason, ams_id and
tray_id. Refusals only -- unlike extrusion_cali_set (#2718) and
ams_filament_drying (#1447) this command is not rare, since every spool
assignment and K-profile re-apply sends one, so promoting each ack would
bury the interesting line.

The developer-mode probe is excluded: it sends this same command to the
external slot expecting a refusal on P1 firmware, so promoting it would
put an alarming line in every P1 bundle on every reconnect. Matched by
sequence id, which user commands cannot collide with -- they publish a
hardcoded "0".

Diagnostics only; no change to which commands are sent or how they are built.
2026-08-04 08:08:55 +02:00
maziggy 689f5276e4 Show the compose directory in the Docker update command (#2664)
The printed command only works from the directory holding the compose
file, which is the thing the user came to the page not knowing. Adds a
copy button, a saved Compose directory setting, BAMBUDDY_COMPOSE_DIR,
and best-effort detection from a bind mount's host path.

Compose records the directory on every container it creates, but reading
that label needs the Docker socket mounted in — root-equivalent access
for a convenience string. The mountinfo guess is a prefill only: its root
field is relative to the mounted device, so a compose dir on its own
mount loses that prefix, and nothing in the container can detect it.

The field is restricted to path characters. It is the one setting whose
purpose is to be pasted into a root shell, so "/opt/bambuddy; rm -rf /"
would otherwise render as a plausible update command.
2026-08-03 15:12:02 +02:00
maziggy a08d3e62f3 Show the Print Log's per-run cost and energy, and let users pick columns (#2636)
The list and update endpoints serialised field by field and never named
cost / energy_kwh / energy_cost, so values Bambuddy had been recording
all along went out as nulls. Both now validate from the ORM row, which
removes the chance to omit a field rather than patching the three that
were missing.

Adds a Filament Used column plus a Columns picker for Cost, Energy,
Energy Cost and Finished, persisted per browser.

Also fixes the log view being unreachable with zero archives: the empty
state ran before the view check, hiding a log that outlives the archives
it refers to.

---

Sort the Print Log by any column (#2636)

Adds sort_by / sort_dir to the print-log endpoint, driven by clickable
column headers. Server-side because paging is: ordering the rows the
client holds would sort one page rather than the log.

Empty values are held last in both directions — Postgres sorts NULLs
high and SQLite low, so the same click would otherwise open on blanks
on one backend and values on the other. id DESC breaks ties so paging
through a low-cardinality sort can't repeat or skip a row.
2026-08-03 14:53:21 +02:00
maziggy e95c42c021 Add auto-orient and auto-arrange to server-side slicing (#2548)
Both are per-slice checkboxes, off by default, forwarded as the sidecar's
orient / arrange form fields. An unticked box is sent by omission: the
sidecar treats any present value as truthy, so a literal "false" would
have arranged every slice.

Arrange unions with the #1493 cross-class decision rather than replacing
it, and the per-plate slice-all loop is now keyed on the arrange flag
itself — the project-wide collapse belongs to --arrange, not to the
cross-class case. The loop also covers the embedded-settings path, whose
crash-retry is suppressed there since a single --slice 0 retry would
return one consolidated plate.
2026-08-03 14:10:02 +02:00
maziggy ea63355fde Fix cross-model queue items being misrepresented and editable into a broken state (#671)
The edit dialog offered a printer picker and target-model dropdown for
an item with alternatives. Saving left a row with variants AND a
printer_id, and the scheduler's fixed-printer branch wins that race, so
it dispatched a row whose library_file_id is still null and failed in
the upload. PATCH now refuses printer/model changes on such an item —
comparing against the current value, since the dialog re-sends
target_model unchanged — and the route eager-loads variants, without
which the guard could not see them and every PATCH response dropped the
alternatives from its payload.

Names come from a shared helper now. A cross-model item holds neither
archive_id nor library_file_id until dispatch, so five separate inlined
fallbacks all rendered "File #null"; they now read "x1c.gcode.3mf +1
more".

The queue also grouped these under "Any H2D" — the first candidate
mirrored onto the row — filing a job under a printer it might never run
on. It groups as "Any H2D / X1C", matching the row beneath it.
2026-08-03 12:33:19 +02:00
maziggy ef7c1b21f1 Add cross-model print alternatives to the File Manager and print modal (#671, #2570)
Selecting several sliced files and pressing Print now creates one queue
item carrying all of them, instead of hiding the Print button the moment
a second file is selected. The printer picker is replaced by the ordered
candidate list, since choosing these files is already the answer to
"which printer" and the only question left is which is preferred.

Per-candidate configuration is the plate only. Model-based assignment
sends no AMS mapping — the printer is unknown until dispatch, where the
scheduler derives it — so a per-candidate mapping editor would collect
choices it then discards. Filament overrides stay shared: "this job
needs PETG" holds for every slice of the same job.

Adds Group as versions for durable grouping, a versions badge counting
the whole group rather than the rows on screen, and a queue card label
naming every model a pending item is waiting on.
2026-08-03 11:33:39 +02:00
maziggy a9b57ccd3c Add variant-group endpoints and cross-model queue creation (#671, #2570)
Adds /library/variant-groups for declaring that several sliced files are
the same job for different printers, and a variants payload on queue
creation that turns such a set into one queue item with a candidate per
file.

The candidate set is validated as a set: one file per printer model, each
file sliced for the model it is offered as, and at least one model with
an active printer. A cross-model item deliberately holds no file of its
own, because print_queue.library_file_id is ON DELETE CASCADE and would
destroy the whole job when a single alternative is deleted.

Fixes internal printer-model codes never being resolved on queue create
and update: normalize_printer_model returns unknown input unchanged, so
the or-chain never reached the code map and a "C13" target matched no
printer and waited forever.

Skips candidates whose file is trashed or missing. Library deletes are
soft, and SQLite runs with PRAGMA foreign_keys off, so neither case is
covered by the schema; the hard-delete paths now also drop the rows.

Adds library_files.variant_target_model so a user can say which printer
a file without slicer metadata is for, kept out of file_metadata so the
assertion is never mistaken for parsed data.
2026-08-03 11:10:11 +02:00
maziggy 752e345d1a Add cross-model variant resolution to the queue scheduler (#671)
Adds print_queue_variants: the candidate files a queue item may run, each
with its own model, plate, AMS mapping and nozzle mapping. The scheduler
walks them in priority order and takes the first whose model has an idle
printer, then folds that candidate onto the queue row before the
selection commit — so upload, archive creation, print history and reprint
keep seeing an ordinary single-file item.

Candidates are ordered least-attempted first, so a printer that accepts
the file and never starts hands the job to the alternative on the next
lap instead of spending the item's whole retry budget on the machine
that is wedged. The item-level DISPATCH_MAX_ATTEMPTS bound is unchanged.

An item whose candidate files have all been deleted is held pending with
an actionable reason rather than failing deep in the upload, and waiting
notifications name the job and every model it is waiting on.
2026-08-03 10:54:45 +02:00
maziggy da07c5884b Add variant-group data model for cross-model queue alternatives (#671)
Adds file_variant_groups plus variant_group_id / variant_position on
library_files, so a set of files that are the same job sliced for
different printers can be resolved to whichever printer frees up first.

Backfills groups from the sliced_from_library_file_id provenance that
slice_and_persist and the pipeline runner have been writing into
file_metadata since they shipped, and which nothing has ever read.
Only sources with two or more children carrying distinct
sliced_for_model values are grouped: a single candidate is not a
choice, and two slices for the same printer give the resolver no basis
to prefer one.
2026-08-03 10:17:28 +02:00
maziggy dd541b20a8 Document prefer_filename_for_name in the OpenAPI schema
The param was a bare bool, so /docs showed an undocumented boolean on
both upload routes. #2609 is about external integrations, and the
interactive docs are where those callers look — a docstring only reaches
someone reading the source. Wraps both in Query(False, description=...),
matching how this file documents its other query params.

Also records why these two routes take the flag per-request while the FTP
review flow and virtual-printer dispatch derive it from the VP-scoped
virtual_printer_archive_name_source setting, and drops the db_session
fixture the four new tests requested but never used.
2026-08-03 09:04:29 +02:00
MartinNYHC 5d1cd943fd Merge branch 'dev' into feature/upload-prefer-filename-for-name 2026-08-03 08:54:09 +02:00
maziggy b04664c64a Raise the chamber-temperature ceiling from 60 to 65 C
Every field that takes a chamber target stopped at 60: the per-filament
chamber map and per-print override in Preheat & Heat Soak, the chamber
quick-select presets, and the printer-card chamber control. 60 is the
X1E's ceiling and the X1E was the only heated-chamber model when that
limit was written; the H2 series and X2D heat to 65, so the top of their
range was unreachable.

The ceiling now lives in one constant per side (MAX_CHAMBER_TEMP_C in
backend/app/utils/printer_models.py and frontend/src/utils/printer.ts)
rather than as a literal at each call site. X1E firmware clamps a higher
request to its own maximum, so a shared ceiling is safe.

Also fixes a live bug at PrintersPage.tsx:7985: parsePresetTriple was
bounded to 60 there, and it rejects the whole triple on any out-of-range
entry, so a saved 65 preset would have silently reverted the printer
card to the defaults while Settings still showed 65.
2026-08-03 08:52:23 +02:00
Sebastian Keet 492d7f27c7 Merge remote-tracking branch 'upstream/dev' into feature/upload-prefer-filename-for-name 2026-08-02 23:14:07 +10:00
Sebastian Keet 0905e374db Merge remote-tracking branch 'origin/feature/upload-prefer-filename-for-name' into feature/upload-prefer-filename-for-name 2026-08-02 23:09:28 +10:00
Sebastian Keet 93443ed7c6 feat: expose prefer_filename_for_name on bulk archive upload too
Maintainer review on #2610 flagged that upload-bulk would diverge from
upload if only the single-file route got the flag. Applies to every
file in the batch, same default-off behavior.
2026-08-02 23:06:50 +10:00
maziggy ad375f6ca7 Housekeeping 2026-08-02 12:29:32 +02:00
maziggy 6484fddc1a Stop the Bambu Cloud TOTP tests reaching the network
verify_totp fetches a CSRF token from the bambulab.com web origin before
posting the code (#2696) and returns early when it cannot get one. These
tests patch only post, so the pre-flight GET went out for real: it succeeded
wherever bambulab.com was reachable and returned a tokenless 403 on a CI
runner, where six tests then asserted on a post that never happened.

Stub the handshake for the module. It is covered end to end, no-token path
included, in tests/unit/test_cloud_totp_csrf.py.
2026-08-02 11:46:03 +02:00
maziggy e52b73e21f Stop the Bambu Cloud TOTP tests reaching the network
verify_totp fetches a CSRF token from the bambulab.com web origin before
posting the code (#2696) and returns early when it cannot get one. These
tests patch only post, so the pre-flight GET went out for real: it succeeded
wherever bambulab.com was reachable and returned a tokenless 403 on a CI
runner, where six tests then asserted on a post that never happened.

Stub the handshake for the module. It is covered end to end, no-token path
included, in tests/unit/test_cloud_totp_csrf.py.
2026-08-02 11:45:53 +02:00
maziggy bbbb9d35c7 Bound the scheme repetition in the log credential-redaction pattern. As an
unbounded repetition the match was quadratic in the subject length: on a run
of scheme-legal characters the engine restarted at every offset and consumed
to the end before failing to find "://". ffmpeg echoes the configured camera
URL into its stderr and the whole blob reaches the pattern before any
truncation, so the subject length is attacker-influenced.
2026-08-02 11:17:49 +02:00
maziggy 3da4eee16e Bound the scheme repetition in the log credential-redaction pattern. As an
unbounded repetition the match was quadratic in the subject length: on a run
of scheme-legal characters the engine restarted at every offset and consumed
to the end before failing to find "://". ffmpeg echoes the configured camera
URL into its stderr and the whole blob reaches the pattern before any
truncation, so the subject length is attacker-influenced.
2026-08-02 11:17:24 +02:00
MartinNYHC 8be3413fbd Merge branch 'main' into dev 2026-08-02 11:10:25 +02:00
maziggy bf424493ba Suppress Bandit B104 false positive in the SSRF guard tests
The parametrize list feeds "0.0.0.0" to TasmotaService._validate_ip and
asserts it is refused. B104 matches the literal wherever it occurs and
cannot distinguish a rejection fixture from a bind address.

Split the list across lines so the token carries its own nosec with the
reason; the single-line form was 117 chars against a 120 limit.
2026-08-02 11:09:32 +02:00
maziggy 3b5d219860 Suppress Bandit B104 false positive in the SSRF guard tests
The parametrize list feeds "0.0.0.0" to TasmotaService._validate_ip and
asserts it is refused. B104 matches the literal wherever it occurs and
cannot distinguish a rejection fixture from a bind address.

Split the list across lines so the token carries its own nosec with the
reason; the single-line form was 117 chars against a 120 limit.
2026-08-02 11:09:08 +02:00
MartinNYHC 81ee7658f5 Merge branch 'main' into 1.2.5.2 2026-08-02 11:04:26 +02:00
maziggy 77135aaf8f Fix unawaited coroutine warning in printer offline notification tests
on_printer_status_change builds reconcile_stale_active_prints(...) as a
call argument, so the coroutine is constructed even when the spawn helper
is mocked out. A bare MagicMock retained it in call_args and it finalised
unawaited during a later test's GC, surfacing as a
PytestUnraisableExceptionWarning attributed to test_printer_sensor_history.

Patch spawn_background_task with a side_effect that closes the coroutine,
and correct the _state() docstring, which claimed state="IDLE" kept the
reconcile-edge branch quiescent when it does the opposite.
2026-08-02 10:23:09 +02:00
maziggy 4cf3235fde Fix unawaited coroutine warning in printer offline notification tests
on_printer_status_change builds reconcile_stale_active_prints(...) as a
call argument, so the coroutine is constructed even when the spawn helper
is mocked out. A bare MagicMock retained it in call_args and it finalised
unawaited during a later test's GC, surfacing as a
PytestUnraisableExceptionWarning attributed to test_printer_sensor_history.

Patch spawn_background_task with a side_effect that closes the coroutine,
and correct the _state() docstring, which claimed state="IDLE" kept the
reconcile-edge branch quiescent when it does the opposite.
2026-08-02 10:22:26 +02:00
maziggy 4ff6377050 Fix per-job queue ETA showing for jobs that cannot start now
The scheduler only writes waiting_reason on the model-based assignment
    path, so a job pinned to a specific printer sits behind a running print
    with no marker at all. Every such job rendered an identical "starts now"
    ETA that was wrong by the length of everything ahead of it.

    Decide eligibility on the page instead: an item gets an ETA only when its
    printer is idle and it is the item the scheduler would dispatch next,
    following the same ordering the scheduler uses. Staged and future-
    scheduled items do not block the item behind them, matching the
    scheduler, and items conditional on a previous print are excluded.

    The value also froze at first render, since react-query's structural
    sharing keeps the queue reference stable and nothing re-rendered the row.
    formatETA now accepts a base instant and the page drives it from a 30s
    clock shared by every visible row.

    Retire the borrowed printers.estimatedCompletion tooltip for a queue key
    that says what the number means, translated into all 13 locales.
2026-08-02 09:54:41 +02:00
maziggy c765d2f2fb fix(kprofiles): stop reporting rejected K-profile writes as saved
Saving a K-profile was fire-and-forget. set_kprofiles_batch published
    and returned True, and the printer's extrusion_cali_set answer was
    logged at DEBUG and dropped, so a write the printer refused was
    reported to the user as saved (#2718, reporter @jmoore-skild).

    The reason it could not simply be gated on: the answer itself was
    wrong. Single-nozzle firmware returned result:"fail" with
    reason:"invalid tray_id" on writes that demonstrably applied.
    Measured against an X1C and an H2D over MQTT, the cause is the
    tray_id:-1 Bambuddy itself put in the payload. Sending three
    otherwise identical writes isolated it: tray_id:-1 fails, tray_id:0
    succeeds, and cali_idx:-1 is accepted either way, so only that one
    field is at fault. The H2D ignores the value entirely; the X1C
    validates it, complains, and applies the write anyway. BambuStudio
    always sends a real tray_id and defaults it to 0 for a manually
    entered profile.

    With tray_id:0 the acknowledgement is honest, and the printer echoes
    back the sequence_id we sent -- confirmed for extrusion_cali_get,
    _set and _del on both printer classes -- so it can be matched to the
    write that caused it. Writes now return their sequence_id and the
    routes await the verdict, turning a real failure into an error that
    carries the printer's own reason. A printer that stays silent is
    still treated as success: no answer is not evidence of refusal, and
    firmware that never answers must not turn every save into an error.

    Raises the ack to INFO. It sat at DEBUG, so the one line that
    explains a failed save was absent from every support bundle -- the
    same reasoning that put ams_filament_drying at INFO for #1447.

    Also fixes extrusion_cali_set building its payload from
    str(self._sequence_id) without incrementing first, reusing the
    previous command's id. Harmless while nothing correlated on it,
    fatal now that the write path does.

    Adds supports_nozzle_flow_type() for the Standard / High Flow choice,
    which the K-Profiles UI previously showed as "Not reported by
    printer" -- not a value anyone can save. Most printers omit the
    nozzle identity from their calibration table entirely, and the slicer
    treats that as Standard rather than unknown; Bambuddy now does the
    same and keeps the choice editable. The field is hidden only where
    the model ships a single nozzle variant, using the slicer's own rule
    (len(nozzle_volume) // len(nozzle_diameter) > 1 over the machine
    preset) evaluated across every bundled Bambu profile. That puts only
    A1, A1 Mini and A2L on the hidden side -- it is not the single-
    versus-dual-nozzle split, since P1P, P1S, P2S, X1, X1C, X1E and H2S
    are all single-nozzle and all carry two variants. Editing a profile
    also no longer writes back an empty nozzle_id.

    Wiki records that on printers which omit the field the chosen flow
    type is discarded by the firmware and reads back as Standard, in
    Bambu Studio as well, so it does not get filed as a bug again.
2026-08-02 09:53:51 +02:00
maziggy 30fcfe117b fix(kprofiles): read the nozzle diameter the printer actually sent (issue #1748)
Every K-profile came back as 0.4mm on printers running any other
    nozzle (#1748, reporters @Liquidmasl and @jmoore-skild). The printer
    puts nozzle_diameter on the extrusion_cali_get envelope only; the
    per-filament entries carry setting_id, filament_id, name, k_value,
    n_coef and cali_idx, and nothing else. The parser read the field per
    entry with a hardcoded "0.4" fallback, so the fallback fired on every
    profile of every response. The envelope value was already in scope,
    read into response_nozzle and used only to match the request.

    This never reproduced on H2D because that firmware does include the
    field per entry. Both construction sites are in the same handler, so
    the code path is shared; what differs is the payload, and every
    single-nozzle model omits it.

    The display was the least of it. Editing is delete-and-re-add on
    single-nozzle printers, and the dialog rebuilt nozzle_id and
    nozzle_diameter from its own greyed-out selects, so saving an
    untouched 0.6mm profile rewrote it on the printer as HH00-0.4.
    Deleting aimed extrusion_cali_del at the wrong nozzle the same way.
    Both now pass through what the printer reported. The cali_idx cascade
    in inventory.py, spoolman_inventory.py and spoolman.py matches on
    nozzle_diameter, so on a 0.6 or 0.8 nozzle it never found the
    printer-side entry and the assignment silently failed to stick --
    that is the "cannot auto-map a K-profile" half of the report, fixed
    at the source without touching those three call sites.

    nozzle_id has no source in the payload at all, and state.nozzles
    carries material (hardened_steel), not flow, so it cannot honestly
    produce HH/HS. Rather than keep inventing one, the UI now says the
    printer did not report it: the card shows the diameter alone, the
    dialog shows "Not reported by printer", and the High Flow / Standard
    filter is hidden instead of being offered as a control that can only
    ever empty the list. Import stops stamping HH00 on profiles whose
    source reported none.

    Also correlates K-profile requests by sequence_id. Responses were
    matched by nozzle diameter through a single shared expectation slot,
    so a second request overwrote the first's and the first's valid
    answer was discarded as a mismatch -- the "Failed to get K-profiles
    after 3 attempts" in the same logs, with the printer having answered
    correctly both times. Pending state is now one entry per request,
    keyed by the id we already send, with the nozzle match kept as a
    fallback for firmware that does not echo it back.

    Fixes the flow-type select naming a new profile with the opposite
    label, which contradicted the identical expression 44 lines above it.
2026-08-02 09:52:52 +02:00
maziggy 8e493318c0 fix(backup): collect cloud profiles from every connected account (#2717)
Enabling Cloud Profiles for a Git backup produced nothing, and said it had
    worked. Two independent faults, either one sufficient.

    The collector looked for a "setting" list. The Bambu Cloud listing endpoint
    is keyed by preset type instead, each key holding private and public arrays,
    so the loop body never executed once — and the entries carry no type of
    their own either, which routes/cloud.py already knew: it takes the type from
    the outer key and maps Bambu's "print" to process. Two bugs on one line.

    It also asked build_authenticated_cloud for the credential store used when
    authentication is disabled. With auth on, tokens live on User rows, so the
    collector returned at "Cloud not authenticated" before ever reaching the bad
    key. Every multi-user install was collecting from zero accounts.

    Neither failure surfaced. backup_metadata.json recorded the configured flag
    rather than the outcome, so it claimed cloud_profiles: true on runs that
    wrote nothing, and the log read "Collected cloud profiles: 0 filament, 0
    printer, 0 process" at INFO — which is exactly what a successful backup of
    an empty account looks like.

    Cloud profiles now come from every connected account across both clouds. The
    toggle predates Orca Cloud entirely, and Orca has the same three preset
    types, so both are collected and grouped the same way:

        cloud_profiles/bambu/user-3/{filament,printer,process}.json
        cloud_profiles/orca/user-3/{filament,printer,process}.json

    Accounts are keyed by Bambuddy user id, "global" when auth is off. Never by
    email: a backup repository can be public, and the Bambu listing's user_id is
    dropped for the same reason. Both credential stores are read on every run,
    because a Settings row survives someone enabling auth later and dropping it
    would silently stop backing that account up.

    Bambu costs one get_setting_detail per private preset. The listing is
    metadata only, and without base_id and setting the backup is a list of names
    that create_setting cannot rebuild from. Public presets are skipped — Bambu's
    bundled catalogue is the same hundreds of entries for everyone, always
    re-downloadable, not recreatable under your account, and would rewrite the
    repository on every run. Orca needs no second call; its sync-pull carries
    each profile's content inline. Where the Orca route drops a profile whose
    content.type it cannot map, the backup writes it to other.json instead:
    silently omitting a profile because Orca added a type is the same class of
    bug as this one.

    Failures are contained per account and per preset, and counted rather than
    swallowed. A partial backup that looks complete is how this stayed invisible.

    The metadata now reports what was collected, per cloud and per account, and a
    run that collects nothing while the category is enabled warns with the reason
    instead of an INFO line that reads like success.

    The checkbox gated on the viewer's own Bambu sign-in, which is not the same
    question as whether there is anything to back up — with auth enabled the
    accounts belong to individual users, and an administrator who never signed
    in personally saw the category disabled with plenty in scope. It now gates
    on the total across both clouds and shows the counts. That comes from its
    own endpoint rather than a field on /config, since /config answers null
    until the first save and would disable the toggle during the very setup it
    belongs to. Counts only, never identities.

    One deliberate restraint. _build_authenticated_service clears stored
    credentials when a refresh is rejected, which is right for a route — the
    user is on the page and can pair again — and wrong for a scheduled job.
    Orca reports every rejection with one composite reason ("unknown, expired,
    revoked, or already used"), so a genuine revocation cannot be told apart
    from a lost token-rotation race, and acting destructively on a signal that
    cannot be disambiguated is the #2562 mistake in a different cloud. It also
    gains nothing: the Profiles route hits the same failure and clears it then,
    with the user present. Background callers now pass clear_on_auth_failure=
    False and skip the account. A successful refresh is still persisted either
    way — by that point the old token is consumed, so dropping the new pair
    would break a working pairing for real.

    Restore is not part of this. Nothing reads cloud_profiles/* yet; the format
    carries base_id/setting for Bambu and content for Orca so that it can.
2026-08-02 09:52:12 +02:00
maziggy 44450a3aa4 fix(vp): gate the slicer's AMS pick behind the toggle and scope its badges (#2700)
Round-3 review of the "Save AMS mapping" PR.

    The queue item's ams_mapping was set unconditionally, on the reasoning that
    honouring the slicer's own pick is a correctness fix rather than a feature.
    It is both. Storing a resolved mapping makes _ensure_ams_mapping return
    early, so _compute_ams_mapping_for_printer never runs — and that function is
    where prefer_lowest_filament lives, along with the AMS-filament-backup gate
    that qualifies it (#1766), the inventory-remain overrides, and the per-slot
    force-colour overrides. Every existing queue-mode VP pointed at a printer
    would have quietly lost all of it on upgrade, without a setting to turn it
    back on.

    So save_ams_mapping now gates the queue item too, not just the archive
    persistence. Off is exactly the old behaviour. The correctness case the PR
    was written for — two spools of the same red PLA, and the slot the user
    picked in the slicer thrown away — is still fixed, for anyone who asks for
    it.

    Force color match wins over it when both are on. Its only effect on a
    fixed-printer item is the filament_overrides written onto the queue item,
    and those are read inside the function a stored mapping skips, so the two
    toggles sitting next to each other on the same card silently cancelled. The
    dispatch now matches strictly, as asked, while the slicer's pick is still
    saved onto the archive — that is what the toggle's name promises, and a
    later reprint is a separate decision from this print. The queue-add fallback
    applies the same rule to a request that carries force-colour overrides.

    A mapping shorter than a plate's highest slot id cannot address that plate's
    own slots, and _ensure_ams_mapping would have kept it anyway, since it only
    rejects an all-unresolved one. Each plate now checks the length it needs and
    falls back to a computed mapping if the array does not reach. Bambu Studio
    sends a file-global array, so this normally never fires; it also means a
    multi-plate Send All degrades safely if that ever stops being true.

    The badges claimed more than they delivered. Both rendered whenever a saved
    mapping existed, ignoring which printer it belonged to, while the tooltips
    promised the reprint would reuse those exact spools — true only on the
    printer the trays were resolved against. The queue row's flag is now
    computed against that row's own printer, which is precisely when dispatch
    reuses the mapping, and the archive card names the printer instead of
    implying any of them will do. It hides itself when that printer no longer
    exists. Retranslated in all 13 locales.

    Frontend tests, which the PR had none of. The printer-scoping rule is now a
    pure function rather than an inline expression, covered for the mismatched
    printer, the no-printer-selected case that would otherwise compare undefined
    against undefined, and malformed extra_data. The toggle's undo bookkeeping
    is covered for unresolved slots, short mappings, and hand-made picks —
    preserved when the toggle never wrote that slot, replaced when it did, which
    is behaviour worth pinning either way.

    Also reverts all three queue-mode switches when a save fails, not just the
    new one; without it the card shows a setting the server rejected.
2026-08-02 09:51:39 +02:00
maziggy 19dcc6d89b feat(support): record process memory, threads and children in bundles (#2734)
A bundle described everything except the process it runs in. So a report of
    memory climbing over days until the OOM killer fires arrives with no way to
    act on it: the numbers that name the mechanism only exist while it is
    happening, and by the time anyone asks, the container has been restarted.

    The new `process` section carries what actually separates the candidates.
    Resident against virtual memory: 650MB RSS with 12.9GB VMS is address
    space — thread stacks or allocator arenas — not a heap full of live data,
    and that reading is the opposite of the one the reporter drew from the same
    figures. Thread count and child-process count then split those two apart,
    and a census of live objects by type names what a growing heap is filling
    up with. Open files, sockets and uptime round it out.

    Three constraints worth keeping:

    The heap census is skipped above 2GB. gc.get_objects() materialises every
    tracked object, so it costs most on exactly the process that can least
    afford it — a bundle generated to diagnose runaway memory must not be the
    allocation that tips the host over. Everything else is still collected, and
    the skip is recorded with its reason rather than silently omitted.

    Children are recorded by executable name only. An ffmpeg command line
    carries the camera URL, and with it the camera's password.

    Collection runs off the event loop and every metric is independently
    best-effort. psutil raises on hardened kernels and in restricted
    containers, and the bundle is how someone reports a problem in the first
    place — it has to be produced even when half the numbers are unavailable.

    This does not fix #2734, and nothing here should be read as having found
    its cause. The bundle's own evidence contradicts both proposed causes: the
    orphan janitor ran 7 times in 26 days over 725 stream-ends and killed no
    orphaned ffmpeg, which is not the #776 signature; and the 5 "database is
    locked" errors all fall between two OOM kills, making them a symptom of the
    memory pressure rather than a source of it.
2026-08-02 09:51:01 +02:00
maziggy bade12ff49 fix(slicer): bound slices by silence, not by total slicing time (#2730)
A heavy MakerWorld model — one Bambu Studio also takes a long time over —
    failed after five minutes with "Slicer sidecar unreachable". The sidecar
    was reachable the whole time and still slicing when we hung up on it.

    SlicerApiService carried a hardcoded 300s timeout, passed to httpx as a
    bare float so it covered connect, read, write and pool alike. On a single
    long request that is not a health check, it is a cap on how long a model is
    allowed to take. And because httpx.ReadTimeout subclasses RequestError,
    expiry landed in the same handler as a refused connection and was reported
    as an unreachable sidecar — so the reporter went and updated their sidecar
    container, which was never the problem.

    The information to do better was already being collected. _poll_progress
    polls /slice/progress/{id} once a second alongside the blocking POST to
    drive the live progress toast, so at minute five Bambuddy had fresh
    evidence the slicer was working. It killed the request anyway.

    So the read timeout comes off the HTTP call and the poller supervises
    instead: the deadline moves forward on every progress update, and only
    genuine silence ends the wait. A model that keeps reporting runs to
    completion however long it takes. Connect and pool keep short timeouts —
    a sidecar that will not accept a connection is unreachable and should
    still say so quickly.

    Only a *changed* progress payload counts as alive. The sidecar re-serves
    its last snapshot on every poll, so counting repeats would leave the
    watchdog unable to detect a stall at all.

    The window is floored at three poll intervals: liveness can only be
    observed as fast as the poller ticks, so anything shorter would expire in
    the gap between two polls and fail every slice instantly.

    New setting slicer_stall_timeout_minutes (Settings > Workflow > Slicer),
    default 15, range 1-240, alongside the sidecar URL and gated on
    use_slicer_api like its neighbours. Sidecars too old to report progress
    have no liveness signal, so for those the same number bounds total elapsed
    time — the old behaviour, configurable and no longer 300s flat. The
    message says which case applies and where to change it.

    SlicerTimeoutError is its own type and maps to 504, not 502: the sidecar
    answered throughout, we stopped waiting. Connection failures keep
    SlicerApiUnavailableError. The preview slice path gets the same treatment.
2026-08-02 09:50:38 +02:00