Commit Graph
100 Commits
Author SHA1 Message Date
maziggy c0a50edbe8 fix(queue): re-check the queue quickly after a dispatch instead of always waiting 30s (issue #2555)
The scheduler slept a fixed 30s after every pass, so each printer that
freed up during a batch waited up to a full interval before its next job
was dispatched — on a farm, that idle gap stacked into the "several long
minutes" reporters saw between requesting prints and them starting (#2555).

check_queue() now reports whether it dispatched anything; run() loops again
after 3s on a productive pass and falls back to 30s otherwise. Fast ticks
only continue while the queue is actively draining, so this can't tight-loop:
a pass that dispatches nothing (all pending items behind busy printers, or a
wedged head-of-line job holding its printer) reverts to the normal interval.
2026-07-15 07:13:22 +02:00
maziggy 62a64006b8 fix(camera): stop /camera/stop from letting a second socket open (#2521)
The single-connection barrier from the last round was correct and was being
bypassed. shutdown_broadcaster() popped the broadcaster out of the registry
and only then awaited its teardown, so while the socket was still closing the
slot sat empty: a /camera/stream request landing in that window minted a
broadcaster with no predecessor and dialled port 6000 immediately. A page
reload fires /camera/stop and the new stream request concurrently, so a P1S
ended up holding two connections, kept feeding the orphan, and starved the
live viewer until its TCP keepalive reaped the dead one ~20 min later. The
stopped broadcaster now stays in the registry so the successor chains behind
its socket close.

The camera page also rendered the <img> src before the stream token arrived
whenever auth was disabled, then swapped it once the token landed — aborting
the in-flight request and issuing a second one. With auth off both reached the
backend, so every load attached two viewers to a one-socket printer. The src
now waits for the token query to settle.

Subscribers only checked for client disconnect after yielding a frame or on a
30s idle timeout, so a viewer that left during a black stream stayed counted —
and /camera/stop trusts that count to decide whether to tear the upstream down.
2026-07-14 11:52:56 +02:00
maziggy 09b739b95d fix(cloud): stop reporting an expired Bambu Cloud sign-in as connected (issue #2562)
An expired token was indistinguishable from a working one. set_token()
stamped token_expiry = now + 30 days every time a stored token was loaded,
so the expiry reset on every request and is_authenticated could never
return False. /cloud/status answered "connected" for as long as any token
existed, while every cloud call 401'd — and the user was shown Bambu's own
{"error": "Please login."} verbatim.

Bambu is now the authority: /cloud/status validates the token upstream
(cached 5m), and any 401 from any authenticated call durably records the
credential as dead via users.cloud_token_invalid_at, so MakerWorld, cloud
profiles, slicer presets and firmware checks all agree at once. An
unreachable Bambu is treated as unknown, never as expired, so an outage
cannot sign a working session out.

The user-facing message now names the Profiles page, where the Bambu Cloud
sign-in actually lives; the old text pointed at a Settings page that does
not exist. Same stale path corrected in the wiki.
2026-07-14 11:29:56 +02:00
maziggy 5540fba459 Updated BACKERS 2026-07-14 10:39:29 +02:00
maziggy ce807fb1cc fix(queue): upload to printers in parallel, cap wedge retries, make debug logs survive a farm
The reporter's 19-printer farm started prints "one by one", up to an hour apart.
check_queue awaited each dispatch inline, and a dispatch includes the FTP upload,
so every printer queued behind every other printer's transfer despite being an
independent machine. His logs give the arithmetic: 40978500 bytes in 254.1s,
157 KB/s - a Bambu printer's SD write, not the network, is the bottleneck. Nineteen
of those in series is ~80 minutes, and the next upload started 131 ms after the
previous one finished. The delay is linear in fleet size, which is why it got worse
the more printers he selected.

Dispatch is now collected during the (still sequential) selection loop and run
concurrently afterwards, capped by queue_max_concurrent_uploads - Settings ->
Workflow -> Queue & Dispatch, default 4, 1 restores the old behaviour. Every gate
is untouched; only the transfers overlap. The pass still awaits its uploads before
returning: _start_print flips the row pending -> printing only after the upload,
so an early return would let the next tick re-dispatch the same rows.

FTP work moves to its own thread pool. It was on asyncio's default executor -
min(32, cpu+4), six threads on a 2-core NAS, shared with everything else - which
was survivable only while uploads were serial.

Two problems the same bundle exposed:

A printer that accepts project_file but never starts (#1678) was retried forever:
270s watchdog, revert to pending, re-upload the whole file, repeat. Hence his
"printer who, since the morning, still not launch" - and on a farm each lap also
eats an upload slot the other printers are waiting on. Attempts are now counted on
the queue item; after three it fails with a message pointing at the printer instead
of queueing a fourth re-upload.

The debug bundle we asked him for held 4m49s of history. The push_status dumps fired
on every frame rather than on change - several while their own comment claimed
otherwise - which is 27,727 of the bundle's 29,830 lines and rolls 5 MB in under five
minutes on 19 printers. They now log transitions only. The bundle also read just the
live log while three rotated backups sat next to it, under a byte budget four times
larger than the file it was reading.

Migration verified on SQLite and Postgres: idempotent, backfills legacy NULLs
(dispatch_attempts + 1 is NULL for a NULL row, which would silently disable the cap).

Tests: 6 on concurrent dispatch (overlap, cap honoured, 1 == serial, default applies
with no settings row, a failed printer does not cancel its siblings, no early return),
4 on the retry budget, 6 on the bundle's rotated-log span, 7 on the debug gating.
Each verified to fail against the unfixed code - the first end-to-end log assertion I
wrote passed without the fix and had to be tightened.
2026-07-14 10:35:58 +02:00
maziggy 5d204b5498 fix(repo-stats): plot ghcr pulls on a linear axis so the trend is visible
The ghcr pulls chart used a symlog y axis with a hardcoded tick list, both
copied from github-repo-stats, which builds the rest of the report. Those
settings suit views and clones - small, spiky, frequently zero - but not
container pulls, which sit in a tight band far above zero.

Consequences on the published report: the series (7,946 to 16,160) lives
entirely inside the top decade of the log scale, so a 2x swing rendered as a
14px wobble on a 200px chart and read as a flat line. Of the nine fixed ticks,
six were squashed against the baseline and 50000 fell outside the domain and
never drew, leaving a single usable gridline.

Switch to a linear scale, drop the fixed ticks so Vega derives them from the
actual domain, and format labels with SI prefixes (5k / 10k / 15k). The zero
baseline and the 10% headroom are unchanged, so the axis stays honest; the same
swing now spans 92px and the growth from ~9k to ~15k pulls/day is legible.
2026-07-13 16:06:59 +02:00
maziggy 4d5dbe8d27 feat(sponsors): ask a print farm for a support contract, not a $5 donation 2026-07-13 11:28:01 +02:00
maziggy 095d63b24a fix(print-modal): give each plate its own Filament Override, and stop
queueing plates we cannot map (#2552)

The override panel disappeared for a multi-plate selection in Any [model]
mode, but only once the dialog had been opened before -- which the reporter
saw as "after the file was queued or printed". The filament requirements are
keyed on the selected plate, which is null as soon as two plates are ticked.
On a cold cache the modal cannot yet tell the file is multi-plate and fetches
the whole file's requirements for one render; the panel rendered from that
union. On a warm cache it knows from the first render, the whole-file fetch
never runs, and the panel had nothing to render. Visibility was decided by a
cache race, and the "working" case listed filaments from plates the user had
not selected.

Model mode now renders one panel per selected plate from that plate's own
requirements, and each queued plate carries only the overrides for the slots
it prints, so a colour forced on one plate no longer blocks another.

Reviewing the per-plate machinery turned up four more holes, all closed here:
a manual tray pick survived a change of printer, and a global tray id names a
different spool on a different machine; a plate whose filaments could not be
read was indistinguishable from one needing none and was queued with neither
mapping nor forced colours, so Print now waits for every selected plate to
answer and names the one it cannot read; the insufficient-filament check still
weighed the whole file against a mapping the plates no longer use, and now
follows what each plate dispatches, summing demand per tray; and the
per-printer tray editor no longer appears for a multi-plate fan-out, where its
choices were collected and then discarded.
2026-07-13 10:25:36 +02:00
maziggy b5da9be794 fix(print-modal): map each plate on its own, and show the panel that does it (#2551)
Selecting several plates hid the filament mapping panel but did not stop the
modal sending a mapping. With no single plate selected it fell back to the
whole file's filament list -- the union of every plate -- and matched against
that. Tray assignment is stateful, so where plate 1 prints red on slot 1 and
plate 2 prints red on slot 2, slot 1 claimed the only red spool and slot 2 fell
through to a type-only match on black. That one mapping went out with every
plate, and the scheduler uses a stored mapping verbatim, so plate 2 printed in
the wrong colour -- decided by a panel the user never saw.

Fetch each selected plate's requirements and map them separately: one panel per
plate, named after it, with its own tray overrides, and each queue item carries
its own plate's mapping. A fan-out across several printers would be a panel per
plate per printer, so those items carry no mapping and the scheduler maps each
plate against the printer it picks. Model mode is unchanged -- no printer means
no trays to map onto.

The tray matcher existed twice and this needed a third caller, so extract it
once and have both existing paths delegate; its 62 tests pass unchanged.

The bug only reproduces with a realistic query cache -- the shared test harness
sets gcTime: 0, which evicts the union and makes the modal look innocent -- so
the new modal tests bring their own client.
2026-07-13 09:44:45 +02:00
maziggy a0d4b3d837 fix(queue): scope force-colour overrides to the plate the item prints (#2551)
Queueing several plates of one 3MF built a single filament-override list from
every selected plate and posted that same list with each plate's item. A
force_color_match entry blocks dispatch until the printer has that exact colour
loaded, so a single-colour plate waited on the whole batch's palette. The same
shared list also widened required_filament_types, making a PLA plate refuse
every printer that lacked a sibling plate's PETG.

Narrow the overrides to the slots the plate actually consumes, on create and on
update -- in the backend, where the 3MF is, so it holds for every writer of the
queue. Dispatch already re-parsed requirements per plate and keyed overrides by
slot, so the dropped entries were inert there. When the plate's slots cannot be
read the overrides are kept whole: an item waiting on a colour it does not need
is visible, one that silently lost a forced colour prints in the wrong filament.

Items queued before this would stay stuck with a waiting reason that explains
nothing, so a startup migration re-scopes the pending ones. Printing and
finished items keep their overrides -- that is a record of what they dispatched
with, not an instruction.
2026-07-13 09:18:17 +02:00
maziggy c640ddc1f7 fix(projects): carry tags, due date and priority in the list payload (#2536)
The edit dialog is shared between the projects list and the project detail
page and seeds itself from whichever project object it is handed. The list
payload never carried tags, due_date or priority, so editing from the list
showed a blank tags field -- and, unreported, submitted the dialog's default
priority over a stored high/urgent one. The component read those fields
through a cast, so the compiler never flagged that they were always absent.

Put them on ProjectListResponse and ProjectListItem, drop the casts, and let
an explicit null clear tags and due date the way it already clears budget and
url -- an emptied field was previously sent as undefined and silently reverted.
The template list was missing target_parts_count, which the same dialog edits.
2026-07-13 08:59:36 +02:00
maziggy 5bbfeefa65 fix(backup): diagnose an unwritable backup path instead of quoting errno 30 (#2544)
Nightly backups to a mounted NAS share ran from May and then stopped, failing
with [Errno 30] Read-only file system. The reporter checked folder permissions
-- correctly: the mount is gid=backup,dir_mode=0775, the service user is in that
group, and his own shell writes to the share fine.

Errno 30 is EROFS. A permission problem is errno 13. EROFS means the filesystem
refused the write, and it refused because we told it to: our systemd unit ships
ProtectSystem=strict, which mounts everything read-only inside the service's
mount namespace and carves back out only ReadWritePaths=<install> <data> <logs>.
A NAS share is not one of those three. Reads are unaffected -- which is why the
UI happily listed his existing backups from the share while being unable to
write a new one -- and his shell is outside the namespace entirely, so every
check he could think to run said the directory was fine.

Both installers write the unit file wholesale, so a ReadWritePaths line added by
hand disappeared on the next install, taking the backups with it. They now back
the old unit up (.bak-<timestamp>) and carry the operator's extra writable paths
forward, reporting which ones they kept. The unit template documents the
carve-out.

The output directory is probed with a real write when it is saved and when the
backup card loads, so an unwritable path is caught there rather than at 03:00
for a week. On failure the card names the cause and hands over the fix with the
operator's path already in it (systemctl edit bambuddy -> ReadWritePaths=...),
and a failed run reports the same diagnosis rather than the raw OSError. EROFS
outside systemd, permission-denied, out-of-space, not-a-directory and missing are
told apart, in all 11 locales.

Docker: a backup path that is not bind-mounted is writable -- the write lands in
the container's ephemeral layer and is lost on the next compose up. The probe
compares the directory's device against the container root and warns, with the
compose snippet that mounts it properly.
2026-07-12 08:44:53 +02:00
maziggy ba1394db3e fix(shutdown): exec uvicorn as PID 1 in Docker, and bound the graceful-shutdown wait
Two defects, both invisible until you ask the app to stop.

Docker never shut down gracefully at all. CMD ["sh","-c","uvicorn ..."] left
the shell as PID 1 with uvicorn as its child, and dash does not forward
signals, so docker stop SIGTERMed the shell and uvicorn never heard about it.
Measured on the shipped image: the full 10s grace period, exit 137, and no
"Shutting down" line in the log. Every stop, restart and image update was a
hard kill -- no WAL checkpoint, no MQTT disconnect, no virtual-printer
teardown. `exec` makes uvicorn PID 1; the rebuilt image now stops in 1s with
exit 0 and checkpoints the WAL.

Separately, uvicorn's timeout_graceful_shutdown defaults to None -- wait
forever for in-flight requests. An MJPEG camera stream is a response that
never completes (httptools' connection shutdown() only flips keep_alive on an
in-flight cycle, it never closes the transport), so one open camera tile
pinned the process until systemd SIGKILLed at 90s. The ordering makes it
unfixable from inside the app: uvicorn fires the lifespan shutdown -- the code
that tears the streams down -- only after connections drain.

All six launchers now pass --timeout-graceful-shutdown 5: Dockerfile,
deploy/bambuddy.service, the systemd unit and launchd plist from
install/install.sh, the SpoolBuddy installer's unit, and the Windows NSSM
registration. On timeout uvicorn cancels the request tasks; the camera
generators already unwind cleanly on CancelledError.

TimeoutStopSec raised to 30s on the units and stop_grace_period: 30s added to
compose, as backstops rather than the mechanism. On Windows NSSM's default
1500ms AppStopMethodConsole was force-killing uvicorn mid-teardown; raised to
15s, with the WM_CLOSE and thread-message stages skipped (uvicorn is a console
app with neither a window nor a message loop).
2026-07-11 14:44:45 +02:00
maziggy aba00598bb fix(smart-plugs): read a REST plug's lifetime counter, and derive Today/Yesterday from it (issue #2539)
A Shelly reports one energy figure — aenergy.total, a lifetime counter in Wh
that never resets. Bambuddy had a single REST energy field and filed whatever
it found under "today", so the value never reset at midnight, and Yesterday
and Total stayed at zero: get_energy() simply never set those keys.

With `total` unpopulated, the hourly snapshot recorder skipped the plug, so
the Statistics page's energy figure was zero as well, not just the Settings
card.

Split the REST energy config in two: rest_energy_path still means "used
today", rest_energy_total_path means "lifetime counter". A Shelly has only
the latter; a Tasmota behind a REST bridge has both; sharing a URL costs one
fetch, not two.

Then derive Today and Yesterday from that counter using the snapshots we were
already taking: today = counter now - counter at the last local midnight;
yesterday = the gap between the two previous midnights. Local midnight, not
UTC — a UTC boundary rolls Today over at 02:00 in Berlin. The snapshot loop
now ticks on the local hour so a reading lands on the boundary instead of up
to an hour early. A counter that goes backwards (factory reset) reports
nothing rather than a negative.

Collateral, found while verifying on both engines: the smart-plug DateTime
columns are naive UTC but the code wrote aware datetimes into them. SQLite
drops the offset; asyncpg raises DataError. So on Postgres every snapshot
capture raised inside the loop's except, and every status poll raised on
last_checked — the whole subsystem was dead on the database we recommend for
multi-printer installs. All plug timestamps are naive UTC now.

Existing REST users with a cumulative path in the today field must move it to
the new lifetime field; the form and wiki now name which counter each wants.
2026-07-11 14:22:31 +02:00
maziggy d09db436c3 feat(camwall): serve the Cam Wall at /camwall, and on a token-authenticated kiosk
Cam Wall had no URL — the only way in was the toggle on the Printers page,
so it could not be bookmarked, linked, or shown on a wall-mounted screen.

Add a standalone /camwall route. Signed in, it is the wall as it was. For a
TV or Pi with no login, it authenticates with a long-lived token in the URL.

A kiosk needs the printer list and per-printer status, both of which sit
behind PRINTERS_READ. Rather than widen camera_stream to cover GET /printers
— whose response carries serial_number and ip_address, which have no business
on a screen in a shared room — add a read-only feed at
GET /api/v1/camwall/printers that serves only what a tile draws, and gate it
on a new camwall token scope. The print filename is not served at all: a token
wall renders the compact overlay, so the part on the bed is never named.

The scope is separate rather than a widening: camera_stream tokens are already
in the wild, minted to hand out video, and must not gain the ability to
enumerate a fleet by name. camera_stream is refused by the feed; camwall
passes the stream gate so its own tiles fill.

Kiosk walls drop the settings popover and click-through entirely (not merely
hidden — a passive screen must carry no focusable control it cannot act on),
cap the overlay at compact, and poll rather than open a WebSocket. maxLive,
interval and status can be set from the URL, clamped to the popover's ranges.
2026-07-11 13:38:15 +02:00
maziggy f2e113ce20 feat(vp): mirror live print progress to the slicer (#1887)
A server-mode VP with a target printer bound showed the print as a bare
filename in Bambu Studio / OrcaSlicer -- no stage, percentage, layer count or
time remaining. The data was already in the bridge cache; we were overwriting
it with zeros, because passing it through made the slicer read the VP as busy
and hide the Send button (#1558).

Both slicers gate the progress panel and the Send button on one predicate,
MachineObject::is_in_printing() -- gcode_state in RUNNING/PAUSE/SLICING/PREPARE
-- so there is no field-level way to have both. FINISH is the one state in the
gap: StatusPanel::update_subtask() renders the panel for it, and
SelectMachineDialog::update_show_status() does not disable Send. The VP already
parks at FINISH after each upload (#1280 / #1658), so it only needed the real
numbers underneath it.

While the target prints and no upload is in flight, the report now holds
gcode_state=FINISH and passes mc_print_stage, mc_percent, mc_remaining_time,
stg, stg_cur, layer_num and total_layer_num through from the cache. Mirroring
is suppressed during PREPARE and for 5s after the last upload transition, so
the slicer still receives the FINISH carrying its own subtask_name and releases
its send modal. print_error is never mirrored -- it would raise a modal error
dialog for a fault the VP did not throw.
2026-07-11 10:06:03 +02:00
maziggy 6b3dd63513 feat(currency): add Philippine Peso (PHP, ₱)
Adds PHP to CURRENCY_SYMBOLS, which feeds both getCurrencySymbol() and the
Settings currency dropdown. Backend stores the code string and needs no change.
2026-07-11 09:37:10 +02:00
maziggy ca3f6e5ee0 fix(drying): P1 AMS drying is screen-only — stop offering it (#2533)
The reporter found what his P1S was doing, and it is in Bambu's P1 manual:
"P1S connected AMS drying functions may only be controlled from the P1S screen."
The firmware acks ams_filament_drying with result: success and then discards it,
which is why three commands on an idle printer left the AMS 2 Pro at dry_status 0.
No command can start a cycle on a P1, on any firmware, so don't offer one.

supports_drying() now excludes the P1 series outright, replacing the 01.08+ gate
carried since #292 — that version is when P1 firmware gained AMS 2 Pro support,
not remote drying, and it was never checked against a live P1. Both drying routes
refuse with a specific 400 instead of publishing a message the printer will drop;
queue and ambient auto-drying skip P1s via the same helper.

A new drying_screen_only flag keeps the control on the card, disabled, saying why
— a P1 owner needs to learn where to dry, not watch the button disappear. A cycle
started at the printer still shows with its countdown; only Stop goes away, since
a P1 ignores stop exactly as it ignores start.

Also corrects the wiki firmware matrix, which listed P1P/P1S as supported and
(separately) P2S/H2S/H2C as unsupported. 8 tests.
2026-07-11 09:33:52 +02:00
maziggy ce31de65c2 fix(skip-objects): scope the object list to the plate being printed (#2522)
extract_printable_objects_from_3mf() has accepted a plate_number since it was
written and no caller ever passed one, so it took root.find(".//plate") — the
first plate in the file. Passing one would not have helped either: the lookup
was .//plate[@plate_idx='N'], a predicate on an attribute neither Bambu Studio
nor OrcaSlicer writes. The index lives in a <metadata key="index"> child, as
threemf_tools and filament_requirements already read it, so the selector never
matched and fell back to plate 1 regardless.

On an all-plates .gcode.3mf that meant Skip Objects offered the wrong plate's
objects, with that plate's marker positions drawn over the correct plate's
thumbnail (/cover resolves the plate properly via resolve_plate_id, the object
list did not). The reporter printed a one-object plate and was shown the four
copies from another plate of the same file.

Select the plate on its index metadata, and pass resolve_plate_id(state) at all
three call sites so the list and the thumbnail share one resolver. Also stop
peek_plate_index_in_3mf() reporting plate 1 for a multi-plate file: it backs the
running one, so an all-plates upload printing plate 2+ lost its archive entirely.
2026-07-11 09:18:35 +02:00
maziggy 3fd3ec06b9 fix(ftp): stop a slow upload from being retried on top of itself (#2529)
upload_file_async carried a flat 600s wall-clock deadline and ran the
transfer via asyncio.wait_for(run_in_executor(...)). wait_for cancels the
future, not the executor thread. A 96 MB 3MF to an A1 over WiFi sustains
~75 KB/s and needs ~20 minutes, so the await gave up at ~70 MB, returned
False, and with_ftp_retry started a second STOR of the same file onto the
same printer while the first was still streaming. The reporter filmed two
transfers of one job climbing in parallel at 2% and 72%; the print never
landed and the printer read as having a flaky network.

The deadline is now derived from the file size against a 25 KB/s floor, so a
slow-but-healthy transfer can finish — a link that has actually died is
caught within socket_timeout by the blocking sendall, which is what should be
detecting failure. A deadline expiry now stops the transfer for real: the
worker is signalled, raises UploadCancelled from its progress callback, and
upload_file's existing cancel path breaks the send loop and deletes the
partial file. with_ftp_retry never retries that, and a per-printer lock makes
overlapping uploads impossible however they were triggered.
2026-07-11 09:05:49 +02:00
maziggy 0c44db04dd fix(ams): confirm drying start instead of trusting the firmware ack (#2533)
The Start Drying button gave no feedback at all and reported success on the
strength of the MQTT ack alone. On a P1S the firmware answers
ams_filament_drying with result=success and then silently declines, and
P1-family firmware never publishes dry_sf_reason — so the #971 reason guard
is inert there and the card just sat unchanged.

Both drying mutations now toast. The start toast claims only that the command
was sent, since that is all the ack proves; the amber countdown badge remains
the signal that a cycle is genuinely live. After a start, the card watches the
unit's dry_status/dry_time (straight from the info bitmask, updated on every
push); firmware reaches DryStatus 1 within seconds of a real start, so still
sitting at zero 30s later means the cycle never began. Bambuddy now says so
and names the two causes: AMS power adapter not connected, or the printer not
idle. Unlike the dry_sf_reason guard this is model-agnostic.
2026-07-11 08:43:34 +02:00
maziggy ccbfcfa295 fix(ams): confirm drying start instead of trusting the firmware ack (#2533)
The Start Drying button gave no feedback at all and reported success on the
strength of the MQTT ack alone. On a P1S the firmware answers
ams_filament_drying with result=success and then silently declines, and
P1-family firmware never publishes dry_sf_reason — so the #971 reason guard
is inert there and the card just sat unchanged.

Both drying mutations now toast on success. After a start, the card watches
the unit's dry_status/dry_time (straight from the info bitmask, updated on
every push); firmware reaches DryStatus 1 within seconds of a real start, so
still sitting at zero 30s later means the cycle never began. Bambuddy now
says so and names the two causes: AMS power adapter not connected, or the
printer not idle. Unlike the dry_sf_reason guard this is model-agnostic.
2026-07-11 08:37:57 +02:00
maziggy 50c3e94d33 fix(cloud): log expected preset misses at DEBUG, keep real faults at WARNING
Failed to get cloud preset ... 400 {"message":"missing"} is the expected
answer, not a fault: many official presets are only addressable with a
printer-variant suffix (GFSL05 exists solely as GFSL05_07 @BBL A1), and
personal P-prefixed presets belong to the account that sliced the file.
Phase 3 already resolves both from local presets, so the lookup miss is
routine -- and one WARNING per AMS tray per tooltip refresh teaches
operators to ignore the log.

BambuCloudError now carries the upstream status_code. The preset lookup
logs HTTP 400 at DEBUG; expired tokens, 5xx and transport failures stay
at WARNING.

Not fixed here: resolving the variant suffix. It selects a printer profile
and the response carries that profile's pressure_advance, so guessing a
suffix would report another printer's K value.
2026-07-10 08:24:20 +02:00
maziggy e6136b660b fix(cloud): carry Bambu Cloud credentials across the auth on/off boundary
get_stored_token() reads the global Settings rows when auth is disabled and
User.cloud_token when it is enabled, so completing /auth/setup switched which
store the /cloud/* routes consult without moving the token. An account linked
before enabling auth was stranded: build_authenticated_cloud() returned None,
get_filament_info() skipped its cloud phase and answered 200 from local
fallbacks, and /cloud/devices began returning 401 -- all silently.

setup_auth() now migrates the global token onto the owning admin and deletes
the global rows; disable_auth() mirrors the hand-off back. Neither guesses:
setup migrates only when it creates the admin or exactly one exists, disable
declines to overwrite an existing global token. Region survives both hops.

Instances that already crossed the transition must re-link once.
2026-07-10 08:15:35 +02:00
maziggy 77f8a3a3ff fix(tls): declare TLS 1.2 as the minimum for printer FTPS and MQTT
ssl.create_default_context() leaves minimum_version at MINIMUM_SUPPORTED,
so the floor came from the OpenSSL build rather than from Bambuddy. On
identical OpenSSL 3.5.6, python:3.13-slim-trixie reports TLSv1_2 while a
bare-metal venv reports MINIMUM_SUPPORTED -- Docker installs were floored
at 1.2, bare-metal and appliance installs were not.

Set minimum_version explicitly in ImplicitFTP_TLS and the MQTT client. On
the P2S/X2D profiles that also cap maximum_version this becomes an exact
TLS 1.2 pin. Probed against an X1C and an H2D on :990 and :8883: both
complete only on TLS 1.2 and reject 1.0, 1.1 and 1.3; live FTPS login
through the new path succeeds on both.

Also correct a stale comment in ftp_profiles.py -- X1C and H2D refuse
TLS 1.3, so cap_tls_v1_2 is a no-op there, contrary to what it claimed.
2026-07-10 07:55:14 +02:00
maziggy d29997fa24 Changed .github/workflows/ci.yml 2026-07-09 16:30:06 +02:00
maziggy a3c1878f11 chore(deps): floor-pin sqlalchemy >=2.0.38, pin ruff exactly, align CI lint
sqlalchemy 2.0.38 switched the aiosqlite file-db pool from NullPool to
AsyncAdaptedQueuePool; _create_engine() passes pool_size/max_overflow on the
SQLite branch, so anything older dies at import. Postgres installs are
unaffected -- the branch is dead there.

The CI lint job ran `pip install ruff` (newest) while requirements-dev.txt
said >=0.8.0, so CI and contributors enforced different rule sets: ruff 0.8.4
reports 32 errors on a tree current ruff calls clean, 30 of them the since-
removed UP038. Pin ruff exactly and have CI install that pin.
2026-07-09 16:29:39 +02:00
maziggy e1e2c12d25 fix(library-tags): declare response_model=None on the 204 DELETE route
Under `from __future__ import annotations` the `-> None` return annotation
reaches FastAPI as the string "None", which resolves to NoneType -- truthy,
so APIRoute asserts a 204 may carry no response body and the app fails to
import. fastapi >= 0.116 guards against this; the 0.109-0.115 releases
requirements.txt still allows do not.
2026-07-09 16:29:13 +02:00
maziggy 179b46580f Make uvicorn bind address configurable via HOST env (default 0.0.0.0)
Allows binding loopback-only (HOST=127.0.0.1) when a reverse proxy on the
same host fronts the app. Default behaviour unchanged.
2026-07-09 15:45:57 +02:00
maziggy f6c6cfbad3 fix(ams): show "?" not "Empty" for non-RFID spools using tray_exist_bits (#2527)
A spool with no readable RFID was reported by the standard AMS with an empty
tray_type and state=9 — structurally identical to a truly-empty slot at the
tray level — so the AMS card rendered it "Empty" while Bambu Studio correctly
showed "?". The authoritative "a spool is physically here" signal is firmware's
AMS-level tray_exist_bits bitmask (what Studio uses), but Bambuddy inferred
emptiness from the per-tray state/tray_type. Confirmed from the reporter's
bundle: tray_exist_bits=f (all four slots present) with tray_is_bbl_bits=5
(only slots 0,2 Bambu) — the present-but-non-Bambu slots were the ones shown
Empty. Supersedes closed #1838.

apply_tray_exist_bits() already parses the bitmask to clear stale fields on
absent slots; it now also annotates each slot with an authoritative `exists`
bool, gated behind a new annotate_exists flag so only the printer-card path
sets it. The VP bridge leaves it off, so the `exists` key never reaches the
slicer wire format. `exists` flows through the AMSTray schema/serialization to
the frontend, where getEmptySlotKind() uses it: exists===true + no tray_type
-> "?" (present, unconfigured), exists===false -> "Empty", exists absent ->
the previous state=9/10 heuristic (AMS-HT and missing-bitmask paths unchanged).
H2D/X1C already reported present-unknown slots with a non-9 state and took the
"?" path; with the fix they reach it via `exists` and are unaffected.
2026-07-09 09:27:13 +02:00
maziggy 9e7f6cafd9 fix(backup): preserve NOT NULL/DEFAULT/FK/UNIQUE in Postgres→SQLite backup (#2526)
On a PostgreSQL install, create_backup_zip() exports a portable SQLite copy
so backups move between engines. It rebuilt each table with only column name
+ type + PK, dropping NOT NULL, server_default/DEFAULT, foreign keys, and
unique constraints. Restore onto SQLite page-copies that schema straight onto
the live database, and post-restore init_db() can't repair it (create_all is
CREATE TABLE IF NOT EXISTS). So server_default columns like
spoolbuddy_devices.created_at (server_default=func.now()) ended up with no
DEFAULT: SQLAlchemy omits them on INSERT, the DB wrote NULL, and the next read
500'd on Pydantic validation. Every server_default column was exposed the same
way; the FK/unique loss followed from the same simplified CREATE TABLE.

Build the portable schema with Base.metadata.create_all() against a SQLite
engine instead of the hand-rolled loop, so it emits the exact DDL a native
SQLite install gets (NOT NULL, DEFAULT func.now() -> CURRENT_TIMESTAMP, FKs,
unique constraints, indexes). The data-export insert path is unchanged, and
the #1333 OIDC-icon guard is preserved automatically (LargeBinary -> BLOB),
which lets the now-redundant _sqlalchemy_type_to_sqlite_type() helper be
removed. Fixes newly-created backups; a backup from an older build still
carries the degraded schema, so re-take backups after upgrading.

Replace the #1333 type-mapping unit tests with three that inspect the real
backup schema via metadata.create_all + PRAGMA table_info: icon_data is BLOB,
created_at keeps its CURRENT_TIMESTAMP DEFAULT, a NOT NULL non-PK column stays
NOT NULL.
2026-07-09 09:02:34 +02:00
maziggy 6127e30abf fix(diagnostic): skip external-storage check on P1S/P1P instead of fail (#2524)
P1-series printers have a MicroSD slot but no reachable control to enable
"Store sent files on external storage": current P1 firmware (through
01.10.00.00) never publishes support_save_remote_print_file_to_storage, so
the Bambu Studio toggle never renders, and the P1S has no screen — leaving
store_to_sdcard stuck False with no way for the user to change it. The
external_storage check reported a permanently-unresolvable fail.

Add NO_REMOTE_STORAGE_TOGGLE_MODELS (P1S, P1P) + has_remote_storage_toggle(),
kept distinct from the no-slot NO_EXTERNAL_STORAGE_MODELS. When a model has a
slot but no reachable toggle and the option is off, the check now emits skip
with params reason=unsupported_model rather than fail, and overall no longer
escalates. A P1S reporting the option on still passes. Model-scoped and
default-open, so X1/P2S/H2 (where the fail is actionable) are unaffected; if
a future firmware surfaces the capability, drop the model and it reactivates.
The frontend DiagnosticChecklist renders a reason-specific message variant
(external_storage.skip_unsupported_model) so P1 users see an accurate
explanation instead of the generic "needs a live MQTT connection" skip text.
The fix propagates to the support-bundle diagnostic snapshot automatically.
2026-07-09 08:43:59 +02:00
maziggy 5dd0370397 fix(finish-photo): bank in-print frame for FINISH-state fallback (#1867)
A1 Mini firmware never emits stg_cur=22, so every completion hits the
FINISH-state fallback — which fires after the End G-code (SwapMod plate
swap) runs, capturing the swapped plate. The last-layer edge trigger
depends on catching one transient MQTT packet and is dropped
intermittently, reverting to the post-swap grab.

Bank a rolling in-print camera frame per printer, refreshed on layer
change. Because it's layer-driven it freezes when printing ends (no more
layer increases during the swap), so the last banked frame is the
finished print. The FINISH-state finish-photo path now prefers the
banked frame over a live grab; stage_22/last_layer still live-grab.
2026-07-09 08:17:59 +02:00
maziggy 616cebdf3f Fix P1/A1 camera black screen from fan-out churn on single-connection cams (#2521)
Chamber-image printers came up black on load and only recovered ~20 min
later. Two causes: (1) late fan-out subscribers got an empty queue and
waited for the next frame, so the browser never fired onLoad and the
stall-detector reconnect-looped, churning short-lived viewers; (2) the
churn reopened the port-6000 socket before the old one closed, so the
printer fed an orphaned socket until its TCP keepalive reaped it.

Prime late subscribers with the last pumped frame; make a replacement
broadcaster's pump wait for the predecessor's socket to close before
dialing (bounded 10s); require two consecutive stalled reads before the
frontend reconnects.
2026-07-09 07:59:41 +02:00
maziggy 1603f52a07 Dock Folder README as a collapsible right rail instead of a top block (#2520)
The README panel rendered full-width above the file grid, pushing model
files below the fold with no page scroll to get past it. On lg+ it now
docks as a fixed-width right-hand column beside the list (own full-height
scroll); on mobile it stacks on top and the page scrolls. Added a collapse
toggle (thin strip / slim bar + one-click reopen) with the choice persisted
to localStorage. New i18n keys readme.show/hide/label across 11 locales.
2026-07-09 07:32:24 +02:00
maziggy d03b108965 Fix external-folder scan deleting README.md records; index markdown (#2520)
.md was missing from _SCANNABLE_EXTENSIONS, so scanning an external
folder skipped markdown during the walk and the cleanup pass deleted
its LibraryFile row (assuming it was gone from disk), 404ing the Folder
Readme panel. Add .md to the scannable set so pre-existing markdown is
indexed, and gate cleanup deletion on actual disk presence rather than
absence from the extension-filtered found_paths, so any non-scannable
upload still on disk survives a scan.
2026-07-09 07:20:55 +02:00
maziggy bb3e2a710e Support non-0.4mm nozzles in AMS Slot config + guard dispatch (#1899)
The Configure AMS Slot picker was hardwired to 0.4mm (nozzleDiameter
prop never passed from PrintersPage / SpoolBuddyAmsPage), so a 0.6
machine could only set 0.4 profiles on its trays. Resolve the real
installed nozzle per-AMS (ams_extruder_map on dual-nozzle) and pass it
in. Separately, nothing validated the sliced nozzle against the
installed one, so a mismatch reached the printer as a cryptic HMS
_8012 "Failed to get AMS mapping table". Add a fail-safe pre-dispatch
guard in _start_print that fails the item with an actionable message
before upload; no slice diameter or no reported nozzles = no-op.
2026-07-08 08:57:56 +02:00
maziggy 55ea4b19c9 Updated BACKERS 2026-07-08 08:02:28 +02:00
maziggy e06677b795 Redirect authenticated visitors off /login (#1889)
LoginPage rendered the credentials form for an already-authenticated
session, so a direct visit to /login (browsers autocomplete the origin
to it) looked like "Remember Me" never worked despite a live token.
Read user/loading from the auth context and redirect to / once the
auth check settles, gated on the credentials step so the 2FA and
OIDC-callback branches keep their own navigation.
2026-07-08 07:49:13 +02:00
maziggy a1a0cbdf39 Updated CHANGELOG 2026-07-07 14:26:40 +02:00
maziggy e083861462 Updated CHANGELOG 2026-07-07 13:53:19 +02:00
maziggy 39ce37885e Bumped version 2026-07-07 13:22:13 +02:00
maziggy 05f82f5f76 Updated .gitignore 2026-07-07 12:45:24 +02:00
maziggy 418e8b56b2 Updated CHANGELOG 2026-07-07 12:19:47 +02:00
maziggy 70312b0a11 fix(scheduler): skip per-nozzle filter under FTS so dispatch feeds the right spool (#2186)
On a dual-nozzle H2C with a Filament Track Switch, a queued print targeting
one nozzle fed a same-type wrong-colour spool: the backend mapping
(_match_filaments_to_slots) hard-filtered candidate trays to the requested
extruder, excluding the correct spool loaded in the other nozzle's AMS — which
the FTS can route across. Confirmed from the reporter's captures: same model
mapped to AMS-A slot 2 (black) on the left nozzle but AMS-B slot 3 (red) on the
right. The #1162 FTS-skip existed only in the frontend mapping, never the
queue-dispatch path.

Read fila_switch.installed in _compute_ams_mapping_for_printer and skip the
per-nozzle filter when an FTS is present. Single-nozzle printers are unaffected
(no nozzle_id in the 3MF, no FTS). Regression tests in TestFtsNozzleBypass.
2026-07-07 12:17:46 +02:00
maziggy 5d64935658 Housekeeping 2026-07-07 11:38:24 +02:00
maziggy 2d8b0005cb Housekeeping 2026-07-07 11:11:15 +02:00
maziggy 5cf429f696 feat(labels): scannable QR on 203 dpi thermal printers + monochrome mode (#1870)
The 40x30 mm box label rendered its QR too densely for low-res thermal
    printers — the modules bled together and wouldn't scan. Two causes: the QR
    was 20% of inner width (~7.5 mm on the narrowest template, half of the
    others) and used ERROR_CORRECT_M. Fix adaptively so all templates benefit:
    give the roomy-layout QR a 12 mm minimum size (box_40x30 -> 12 mm, ~3.5
    dots/module at 203 dpi) and switch label QRs to ERROR_CORRECT_L (same
    payload, chunkier modules; a label needs no M-level recovery). Keep the
    quiet-zone border at 2 — the size+L gains suffice without risking scans.

    Also add a Monochrome (black & white printer) option to the label dialog:
    drops the colour swatch (a useless grey block on B&W) and widens the text;
    the hex-code line still carries the colour. Threaded through the renderer,
    route, API client, and modal, with translations in all 11 locales.
2026-07-07 11:07:08 +02:00
maziggy d8d3cde830 fix(scheduler): default require_plate_clear to False to match schema/UI (#1865)
check_queue() read the plate-clear setting with _get_bool_setting(default=True),
    but SettingsSchema.require_plate_clear defaults False and the whole frontend
    treats a missing value as off. Since _get_bool_setting returns its default when
    no DB row exists, installs that never saved the setting enforced the plate-clear
    gate the UI showed as disabled — FINISH-state printers never dispatched and no UI
    control existed to clear awaiting_plate_clear. Read the setting with default=False
    so the enforced behavior matches the schema and the toggle. Both defaults shipped
    together in #752; this aligns them.
2026-07-07 11:06:48 +02:00
maziggy 56185d2bac fix(ui): resolve light-theme low-contrast semantic text app-wide (#1909)
The app was built dark-first, so hundreds of hardcoded Tailwind semantic
    text/icon utilities at light shades (text-amber-400, text-blue-300, ...) had
    no dark: variant. With darkMode:'class' they applied in light theme too,
    producing washed-out text on pale tints and white cards — including the three
    reported spots (AMS Drying banner, Archives no-3MF warning, debug-logging
    banner). Give each a theme-aware pair: a darker readable shade in light theme
    with the original pinned to dark:, so dark theme is unchanged. ~100 files.

    The bambu-* CSS-variable palette (self-correcting) and the dark-only SpoolBuddy
    kiosk are left untouched. Plain text-white is already theme-aware via the
    existing index.css .text-white override, so it needed no changes.
2026-07-07 11:06:27 +02:00
maziggy 85bd68b0cc fix(sponsor): anchor 14-day toast cooldown on show, not just on CTA click (#2477)
The sponsor toast re-fired on every fresh browser session. The backend
    owns the 14-day cooldown but only persists the anchor (last_shown_at) and
    the seen-milestone record inside POST /sponsor-prompt/dismiss, and the hook
    only called dismiss from the "View supporters" CTA onClick. A user who saw
    the toast but never clicked the CTA persisted no state; the per-tab
    sessionStorage guard hid the re-fire within one session, but every new
    session re-checked against empty state and re-showed the same milestone.

    Record the toast as shown the moment it renders (POST /dismiss right after
    showPersistentToast) so display is what arms the cooldown. CTA click stays
    optional and just navigates. Frontend-only; backend cooldown logic unchanged.
2026-07-07 11:06:10 +02:00
maziggy daecfe6ca0 fix(windows): bundle vcruntime140_1.dll so greenlet loads on fresh Win10 (#2474)
A clean Windows 10 install crashed on startup: init_db() -> SQLAlchemy async
    engine -> greenlet failed with "DLL load failed while importing _greenlet:
    The specified module could not be found", so uvicorn never bound :8000 and the
    dashboard refused all connections while the NSSM service still showed running.

    greenlet's _greenlet.pyd is C++ and needs vcruntime140_1.dll, which the
    python.org embeddable distribution does not ship (it includes only
    vcruntime140.dll, enough for the pure-C python313.dll). Machines with the VC++
    2015-2022 redistributable already installed have the DLL in System32, which
    masked the bug in testing.

    Stage vcruntime140_1.dll and msvcp140.dll next to python.exe at build time,
    from a vendored copy or the runner's System32, failing loudly if absent. The
    Inno Setup [Files] step already copies staging\python\* recursively.
2026-07-07 11:05:52 +02:00
maziggy 2119ddd4f9 fix(vp): populate bind-interface list on macOS (route non-Linux to psutil)
get_network_interfaces() only sent Windows to the psutil path; macOS fell into
    the Linux ioctl branch, whose SIOCGIFADDR/SIOCGIFNETMASK ioctls are Linux-only.
    macOS/BSD have fcntl but different ioctl numbers, so every call raised OSError
    and the function returned an empty list — the VP bind-interface dropdown showed
    nothing. Route all non-Linux platforms through the cross-platform psutil path.
2026-07-07 11:05:35 +02:00
maziggy ae7674b3c1 fix(install): make macOS native install rootless (brew + venv permission errors)
macOS mixed root-only steps (default /opt path, sudo git clone) with steps
    that must not run as root: brew refuses to run as root, and a root-owned
    venv/node_modules can't be managed by the launchd agent. The installer now
    refuses sudo on macOS, defaults to ~/bambuddy, and drops sudo from the
    download/venv/frontend/env/dir steps. A --path under a root-owned parent still
    works via a single elevate-and-chown. Linux (service user + systemd) unchanged.
2026-07-07 11:05:17 +02:00
maziggy e3fe2971db fix(camera): transcode non-JPEG external snapshots to JPEG (#1902)
External cameras in HTTP-snapshot mode failed to load with a repeating
    "connection lost" when the endpoint served PNG/WebP/BMP stills instead of
    JPEG (common on IP cameras and reverse-proxied snapshot URLs). The URL
    rendered fine directly in a browser, but Bambuddy's MJPEG stream wraps
    every part in a hard-coded Content-Type: image/jpeg boundary, so a
    non-JPEG payload labelled as JPEG made the browser reject the frame and
    tear down the whole multipart/x-mixed-replace stream.

    _capture_snapshot now transcodes non-JPEG stills to JPEG via OpenCV
    (already a dependency). Genuine JPEG snapshots keep a byte-for-byte fast
    path; truly undecodable responses (HTML error pages, auth redirects) fall
    back to the previous raw-return behaviour with a single clear warning
    instead of a per-frame log flood.
2026-07-07 11:04:58 +02:00
maziggy 33554072ed fix(ui): restore missing per-user Notifications nav item (#1901)
The sidebar-ordering refactor in #1673 accidentally dropped the
    `notifications` entry from `defaultNavItems` and its
    `notifications:user_email` permission mapping, but kept the advanced-auth
    visibility gate that references that id. With no nav entry the id never
    enters the render set, so the /notifications page (route, page, and API
    all intact) became reachable only by typing the URL — users could no
    longer opt in/out of their own print email notifications from the menu.

    Restore both the defaultNavItems entry and the permission gate, matching
    the permission the user-email-preferences API actually requires
    (notifications:user_email, held by both default groups). Add comments so
    the entry isn't dropped again in a future sidebar refactor.
2026-07-07 11:04:33 +02:00
maziggy 6e03ecdb8d fix(vp): stop uvloop from silently truncating VP FTP uploads (#1896)
Native (non-Docker) installs launched uvicorn without --loop asyncio, so
    uvicorn[standard] auto-selected uvloop. uvloop's SSL layer drops
    already-received but still-buffered data when the client closes the data
    connection without a TLS close_notify while the reader is flow-control
    paused on slow storage. cmd_STOR writes each chunk to disk inside the read
    loop, so a slow consumer falls behind, the tail is lost, read() returns a
    clean EOF, and the loop exits with no exception -- the server acked 226 for
    a file it truncated itself, then archived, queued, and forwarded the corrupt
    3MF to the real printer.

    Fix in two independent layers:

    1. Remove the trigger: add --loop asyncio to every native launch path,
       matching the Dockerfile -- deploy/bambuddy.service, install/install.sh
       (systemd + launchd), spoolbuddy/install/install.sh, the Windows NSSM
       service, README, CONTRIBUTING dev command.

    2. Defense in depth (loop-independent): cmd_STOR now validates that a
       received .3mf opens as a ZIP (reads the central directory, no
       decompression) before replying 226. A truncated/corrupt file is dropped
       and answered with 426, and on_file_received never runs -- so a broken
       upload surfaces as an immediate slicer-side send error instead of being
       archived and pushed to the printer. Scoped to .3mf; other filetypes pass
       through unchanged.
2026-07-07 11:04:04 +02:00
maziggy c5b02d9473 fix(auth): let API keys manage projects via new can_manage_projects scope (#1893)
PROJECTS_CREATE/UPDATE/DELETE were in _APIKEY_DENIED_PERMISSIONS with no
    entry in _APIKEY_SCOPE_BY_PERMISSION, so every project mutation returned a
    generic 403 for any API key regardless of granted permissions -- the same
    regression class as archives (#1888) and library (#1832).

    Add a per-key can_manage_projects scope. Project routes gate on plain
    PROJECTS_* (no OWN/ALL split), so all three CRUD permissions map to the one
    scope; membership edits (add-archives) gate on PROJECTS_UPDATE and are
    covered. PROJECTS_READ is unchanged (already under can_read_status).

    Column defaults TRUE for new keys; existing rows backfill to FALSE so the
    upgrade never silently widens scope. Migration is BOOLEAN (SQLite + Postgres
    safe), verified on fresh SQLite and Postgres 17. Bundled SpoolBuddy kiosk key
    set to False. Settings API-key UI gets a Manage Projects toggle + Projects
    badge; 11-locale i18n. RBAC scope matrix + drift guards extended.
2026-07-07 11:03:46 +02:00
maziggy 18dbe63fd5 fix(drying): don't stop a running AMS dry on an unreliable humidity re-check (#1892)
Auto-drying stopped manually started (and pre-restart) AMS drying cycles
    after exactly 30 minutes. The already-drying branch in _check_auto_drying()
    applied a humidity-based auto-stop despite its own "track but don't stop"
    comment, and the humidity re-check is unreliable: RH drops steeply in heated
    air, so the sensor reads ~15-20% within minutes of the dryer starting even
    with saturated filament. humidity <= threshold was thus effectively always
    true, and the _min_drying_seconds=1800 floor pinned the stop to the 30-minute
    mark. This also truncated Bambuddy's own preset-duration dries.

    Remove the humidity-based early-stop entirely: a running dry now runs to its
    configured duration (firmware stops it). Scheduling stops (print priority,
    queue no longer needing the dry) are unchanged via _stop_drying(). Drop the
    now-unused _min_drying_seconds.
2026-07-07 11:03:25 +02:00
maziggy c751047ed8 fix(websocket): stop the ws-token reconnect loop on auth failure
After the GHSA-r2qv gate (b7d7c825), /api/v1/ws needs a token from
    POST /api/v1/auth/ws-token (Permission.WEBSOCKET_CONNECT). When the mint
    failed, useWebSocket swallowed the error, opened a tokenless socket, the
    server closed it 4401, and ws.onclose rescheduled connect() every 3s -
    an endless loop that hammered /auth/ws-token. The dominant trigger is a
    validly-logged-in user whose group lacks WEBSOCKET_CONNECT (mint returns
    403). A secondary leak: the unmount-triggered onclose could schedule a
    post-unmount reconnect.

    Classify the mint failure: 401 (JWT expired; request() already clears it
    and dispatches auth:expired) or 403 (valid session, missing permission;
    degrade to REST polling) now stop the hook - no tokenless socket, no
    reconnect. A 4401 close is terminal. Network/5xx still reconnect. A
    disposedRef set in cleanup before close() prevents the unmount-race
    reconnect. Same 401/403 no-open guard applied to StreamOverlayPage.

    Also surface a one-line hint under the WebSocket permission in the group
    editor (all 11 locales) explaining that live updates need it and fall
    back to polling without it - rather than auto-granting the permission,
    which would partly undo the GHSA-r2qv gate.
2026-07-07 11:03:00 +02:00
maziggy 640c7daaaa fix(auth): don't discard a valid stored token on a transient load-time error (#1889)
On mount, AuthContext.checkAuthStatus restores the persisted "Remember Me"
    token from localStorage and validates it via GET /auth/me. The catch around
    that call cleared the token on ANY failure, not just a definitive 401
    invalid-token — so a brief backend-not-ready or reverse-proxy hiccup during
    page load (plausible right after a container restart, e.g. on Unraid) would
    delete a still-valid token. Because the token was deleted, a reload couldn't
    recover it and the user was bounced to the login screen.

    Token validation now retries transient failures (up to 3 attempts with short
    backoff) and only discards the token on a definitive 401 — which request()
    already handles (clears the token and dispatches auth:expired). Transient /
    5xx / network errors leave the persisted token intact so the session survives
    a slow load. "Remember Me" stays client-storage only; it does not extend the
    server-side JWT lifetime (session_max_hours, default 24h).

    Adds AuthContext tests: transient /auth/me failure keeps the token, a
    definitive 401 clears it, and a valid token loads the user. Rebuilt frontend
    bundle.
2026-07-07 11:02:40 +02:00
maziggy 1fd1825b71 fix(smart-plug): don't cut power when a print restarts, honor per-plug cooldown setting (#1890)
The print-queue "auto off after this job" trigger used a second, inline
    auto-off implementation (main.py, print_scheduler.py, print_queue.py)
    that hardcoded wait_for_cooldown(50C, 600s) — ignoring each plug's
    configured off_delay_mode / off_delay_minutes / off_temp_threshold — and
    ignored the return value, powering off on the 600s timeout regardless of
    print state. A print that failed and was reprinted from the touchscreen
    got its power cut mid-print. The inline tasks were also uncancellable, so
    a reprint couldn't abort a pending off.

    Consolidate all three into SmartPlugManager.schedule_off_after_queue_job,
    which schedules via the plug's configured strategy (shared with
    on_print_complete through _schedule_off_per_mode) and is cancellable via
    _pending_off. Add printer_manager.is_print_active() and guard the actual
    power-off in _delayed_off and _temp_based_off so no path cuts power on a
    loaded print. Move the on_print_start cancellation ahead of the auto_on
    gate so a reprint always aborts a pending off.
2026-07-07 11:02:21 +02:00
maziggy 99d06f3cd1 fix(auth): allow API keys to delete/edit archives via new can_manage_archives scope (#1888)
DELETE /api/v1/archives/{id} rejected every API key with 403
    "API keys cannot be used for administrative operations", regardless of
    the print's owner or the key's scopes. ARCHIVES_DELETE_ALL/_OWN (and the
    create/update variants) were on the denylist and absent from the scope
    allowlist, so require_ownership_permission fell through to the generic
    admin-denied 403 — the whole archive-management surface was unreachable
    for API keys. Same regression class as the #1832 library/maintenance
    carve-outs.

    Add a can_manage_archives per-key scope: ARCHIVES_CREATE, ARCHIVES_
    UPDATE_OWN/_ALL and ARCHIVES_DELETE_OWN/_ALL move from the denylist to
    the allowlist under it (OWN and ALL fold into the same scope, matching
    can_manage_library). ARCHIVES_PURGE stays admin-only — it drops the
    print's Quick Stats contribution, mirroring LIBRARY_PURGE. Column
    defaults TRUE for UI-created keys; existing rows backfill to FALSE so the
    upgrade never silently widens scope. Bundled SpoolBuddy kiosk key stays
    minimally scoped (False). Migration is dialect-agnostic and verified on
    fresh SQLite and Postgres 17.

    Adds the Settings API-key toggle + badge (11-locale i18n) and extends the
    RBAC scope matrix to cover all five archive-management permissions.
2026-07-07 11:01:57 +02:00
maziggy 11d73b0a64 fix(slicer): preserve PVA-for-support intent across re-slice of source 3MF (#1881)
Three bugs on the same PLA-model + PVA-support flow, discovered in
    sequence:

    (A) substitute_unused_plate_filaments inspected only object geometry
        (per-object extruder metadata + paint_color triangles) so a support-
        only slot was silently treated as "unused" and the user's PVA profile
        got overwritten with slot 1's PLA.

    (B) _extract_filament_info stripped filament_is_support==1 entries,
        hiding PVA from unsliced source archive cards even when the project
        explicitly configured it.

    (C) --load-settings is authoritative over the source's project_settings.
        config, and Bambu's shipped process presets ship enable_support=0
        (supports are a per-print decision, not per-quality). So even with
        (A) fixed, the sliced output had supports disabled and the PVA slot
        loaded but never consumed. Inverts BambuStudio GUI's semantics where
        the project overrides the preset.

    Fixes:
    - New extract_support_filament_slots_from_3mf reads enable_support +
      support_filament + support_interface_filament from project_settings.
      config; substitute_unused_plate_filaments unions it into the geometry-
      derived set.
    - _extract_filament_info returns all configured filament types + colours.
    - New _patch_process_support_settings overlays four fields (enable_
      support, support_filament, support_interface_filament, support_type)
      from the source 3MF onto the picked process preset JSON before
      --load-settings sees it. Deliberately targeted to what fixes #1881
      without widening to a full project-over-preset merge.
2026-07-07 11:01:25 +02:00
maziggy b6da148890 fix(vp): evict MQTT clients on drain timeout + tighten TCP keepalive (#1872)
Reporter (H2C + macOS 26.5.1 + BS 2.8.0.50): after every Mac sleep/wake
    cycle, Bambu Studio couldn't see the VP or connect to it. Only fix was
    quit BS + reboot Bambuddy. The physical printer's own cloud/LAN link
    recovered in ~5 s from the same sleep — the delta was in VP session
    handling.

    Log evidence (bug-report-assets/logs/ddf1ede75df045cd94ad223d0f08f88a):

    - 14:04:06 healthy `1Hz status push: 60 pushes/min to :54698`
    - 14:04:06 → 14:09:16: five minutes of SSDP-only, no push summary for
      :54698, no OSError, no disconnect line
    - 14:09:16: new source port :54861 connects and authenticates fine —
      the server was not rejecting reconnects
    - 14:10:17 first DEBUG line: `MQTT drain timeout for
      device/…/report — client may be busy` — smoking gun

    Root cause: `_publish_to_report:1149` caught `asyncio.wait_for(drain,
    timeout=5)` TimeoutError at DEBUG and returned silently. TimeoutError
    is not OSError, so the push loop's `except OSError` at :441 never saw
    it — the zombie writer sat in self._clients until the kernel's default
    TCP keepalive detected the dead peer (Linux default: ~2 h 11 min).

    Two hunks:

    1. `_publish_to_report`: on drain TimeoutError, close the writer (best
       effort, catch Exception so an already-broken close() doesn't mask
       the raise) and raise BrokenPipeError, which IS OSError. Push loop
       evicts on the same tick.

    2. `_handle_client`: after SO_KEEPALIVE=1, set TCP_KEEPIDLE=60,
       TCP_KEEPINTVL=15, TCP_KEEPCNT=4 — dead-peer detection in ~2 min
       instead of ~2 h. `getattr(socket, ...)` guards keep it cross-
       platform (macOS uses TCP_KEEPALIVE not TCP_KEEPIDLE, other kernels
       may not expose all three — skip whichever is missing).

    What I got wrong first pass and corrected on log-read: hypothesised
    "missing MQTT session takeover on same client_id". Wrong. _handle_connect
    parses the protocol client_id but discards it (assignment commented out
    at :762), and self._clients is keyed on `f"{addr[0]}:{addr[1]}"` (socket
    peer), so every reconnect gets a distinct key. No takeover race exists.
    The log fixed this: the "not seen" symptom is BS-side (macOS UDP
    receive after sleep + BS holding the pre-sleep socket state), but the
    server-side amplifier was the zombie writer.
2026-07-07 11:00:55 +02:00
maziggy 6d10e3ff89 fix(vp): route non-proxy camera passthrough by target model — 6000 for A1/P1 (#1868)
Non-proxy VP mode hardcoded the camera-passthrough TCPProxy to
    listen_port=322 / target_port=322 regardless of the target printer's
    model. That port is correct for RTSPS models (X1/X2/H2/P2S), but A1 /
    A1 Mini / P1P / P1S use Bambu's proprietary chamber-image protocol on
    port 6000. Result: A1/P1 targets got a 322 listener with no upstream,
    OrcaSlicer Liveview failed with [2:-10061], BambuStudio's camera button
    timed out.

    Reporter confirmed a raw socat forwarder `<VP-IP>:6000 → <P1S-IP>:6000`
    restored the stream — the target camera works, the VP just wasn't
    publishing it.

    Proxy mode was unaffected because SlicerProxyManager already opens 6000
    (nominally file-transfer; Bambu reuses the port for chamber-image), so
    the passthrough coincidentally works there.

    Fix: read the target's model from
    `printer_manager.get_client(target_id).model` at the same point we read
    target_ip, then use `get_camera_port(target_model)` — the same source of
    truth as routes/camera.py — to pick 322 or 6000. Model comes from the
    physical printer, NOT self.model (the VP's spoofed identity has no
    bearing on how the real device serves its camera).

    Renamed the log tag from "RTSP" to f"Camera-{camera_port}" so support
    bundles show which protocol the VP is fronting at a glance. Kept the
    _rtsp_proxy attribute name to keep the diff tight; the block comment
    spells out that it doubles as chamber-image passthrough on A1/P1.
2026-07-07 11:00:32 +02:00
maziggy 0c7480f9ea fix(queue): edit modal shows printer/model selection for model-assigned items
Editing a queue item that was created with "Any of model X" left the
    printer selection area completely blank — the assignmentMode was
    initialised to 'model' from queueItem.target_model, but the three
    model-mode props (onAssignmentModeChange, onTargetModelChange,
    onTargetLocationChange) were gated behind !isEditing. That flipped
    modelAssignmentAvailable to false in PrinterSelector and hid the mode
    toggle, the model dropdown, AND the location filter; combined with the
    assignmentMode === 'printer' gate on the printer list, the whole
    selector rendered empty.

    Users hit this whenever they queued something to "Any of model X" and
    then wanted to change the target model / location — the only workaround
    was delete + re-queue.

    Fix: drop the !isEditing gate on all three PrinterSelector props. The
    submit path already handles both flavours (target_model+target_location
    with printer_id=null vs. printer_id with the target fields nulled), so
    un-gating the UI just surfaces the machinery that was already there.
    Edit is still only offered on pending items, so the mode-flip can't race
    an in-flight dispatch.
2026-07-07 11:00:08 +02:00
maziggy 8b49edf811 fix(mqtt): capture finish photo on last-layer edge, not FINISH state (#1867)
A1 Mini firmware skips stg_cur=22 entirely, so the finish-photo fallback
    fires at gcode_state=FINISH — which runs AFTER Bambu Studio has already
    executed the user's End G-code. Users with SwapMod plate-swap injected
    into End G-code always got a photo of the swapped (empty) plate.

    Add a layer_num >= total_layer_num edge trigger in _parse_print_data so
    the pre-capture fires the moment the last object layer completes, on
    every printer variant. Guarded by the existing _finish_photo_captured
    one-shot so stage-22 and FINISH-state hooks become no-ops for the same
    print — no framing regression on AMS printers without custom end G-code.
2026-07-07 10:59:36 +02:00
maziggy 703dc693d7 feat(currency): add Indonesian Rupiah (IDR) support (#1869)
Adds IDR with Rp symbol to the supported currencies list, available
    in Settings → Cost Tracking.
2026-07-07 10:59:06 +02:00
maziggy 484ea9c2a4 fix(spoolman): split mid-print usage across AMS backup switch (#1793)
usage_tracker's tray-switch split has never had a Spoolman peer.
    An AMS same-material runout switch mid-print charged the whole slot
    to the origin spool via the (via tag) path and double-credited the
    backup via remain-delta — origin exceeded initial_weight.

    Extract the segment-math into utils/tray_split.compute_tray_split_grams
    and call it from both writers so the two inventory backends attribute
    mid-print switches identically. spoolman_tracking gains
    _report_spool_usage_split_by_tray_changes; the Path 2 remain-delta
    fallback now skips trays the split path covered, killing the
    double-count.
2026-07-07 10:58:24 +02:00
maziggy b8b5aaa977 feat(api-keys): can_manage_maintenance scope for HA-style automations (#1832 follow-up)
Carve MAINTENANCE_CREATE/UPDATE/DELETE out of the admin denylist so
    HA automations can log "cleaned nozzle" / reset a counter via API key
    without granting broader printer control. Follows the same shape as
    can_manage_library and can_manage_inventory: new column, allowlist
    entry, UI checkbox, wiki row, RBAC test coverage.

    Distinct backfill: these perms were EXPLICITLY denied for every API
    key before this change (no existing integration relies on them), so
    existing rows migrate to FALSE — no silent scope widening on upgrade.
    New keys default to TRUE, matching the safe-on-by-default pattern.
    Bundled SpoolBuddy kiosk key gets False explicitly (kiosk doesn't need it).
2026-07-07 10:58:09 +02:00
maziggy 7b18bbfc1d fix(printers): drop P1S / P1P from door-sensor badge whitelist (#1866)
P1S has an enclosure door but no hall sensor for it; P1P has no
    enclosure at all. Both models were rendering a permanent green
    "Door Closed" chip driven by bit 23 of the stat field, which stays
    0 forever on that firmware. Whitelist now covers only models that
    actually ship with a door sensor: X1 family, X2D, P2S, and H2 family.
    Corrected the matching stale comments in the PrinterStatus TS
    interface (client.ts) and PrinterState dataclass (bambu_mqtt.py).

    Backend parse left as-is — cheap and future-proof if Bambu ever
    wires the P-series enclosure into a sensor.
2026-07-07 10:57:50 +02:00
maziggy 5d400d2e6e fix(cloud): send required ?version= param on singular GET/DELETE of slicer setting endpoint (#1815)
get_setting_detail and delete_setting were hitting
    /v1/iot-service/api/slicer/setting/{id} without the version query
    parameter Bambu Cloud requires — every call returned HTTP 400
    "field 'version' is not set". The sibling plural GET
    (get_slicer_settings) has always sent it; the comment above
    _SLICER_API_VERSION documents the contract for the endpoint subtree.
    Missed when the placeholder landed in the 2026-05-12 compliance rework.

    Downstream effect: slicer_filament_resolver.resolve_slicer_filament's
    PFUS branch swallowed the 400, fell through to normalize_slicer_filament,
    and caller inventory.py generic-material-fell-back tray_info_idx to
    GFL99/GFG99. BambuStudio's AMS panel reads the printer's tray_info_idx
    echo, so the user saw "Generic PLA" instead of the custom cloud preset.

    Masked for 50 days by two rescue paths in the caller: prior-slot
    tray_info_idx reuse, and stored spool_k_profile → live state.kprofiles
    realign. Reporter's spool 54 → tray 2 assign had neither.

    Adjacent surfaces also fixed by the same two-line change: the delete
    cloud preset UI route, the whole update_setting flow (get_setting_detail
    → delete_setting → POST), preset_resolver's cloud branch, and three
    UI-facing cloud.py routes that fetch setting detail.

    get_setting_detail also includes the truncated response body in the
    raised BambuCloudError so the next contract change is self-diagnostic
    from support-bundle logs.
2026-07-07 10:57:33 +02:00
maziggy ca37d1e200 ci(repo-stats): clone the github-repo-stats data branch, not gh-pages
The opaque "exit code 1" from actions/checkout was actually masking a
    "Remote branch gh-pages not found in upstream origin" — the bambuddy
    repo doesn't have a gh-pages branch. jgehrcke/github-repo-stats writes
    to a branch called `github-repo-stats` by default, and Pages is wired
    to serve from that branch. The path under it (maziggy/bambuddy/latest-
    report/report.html) is unchanged.

    Switching the clone branch and renaming the local path from `gh-pages/`
    to `data/` so the workflow reads correctly. The PAT-in-URL pattern and
    the direct git clone (vs actions/checkout) stay — both still wanted so
    the real git stderr reaches the log if anything goes wrong.

    Verified by cloning `github-repo-stats` locally and running the
    injector against its live report.html: clean patch, 30 days extracted,
    all anchor markers (TOC, section, script) present.
2026-07-07 10:57:16 +02:00
maziggy 259cf7a975 ci(repo-stats): swap actions/checkout for direct git clone on gh-pages
The PAT-token swap didn't fix the gh-pages fetch — same opaque "exit
    code 1" from actions/checkout@v4 on both attempts of the retry loop,
    with no underlying git stderr surfaced. Likely the wildcard-refspec
    + shallow fetch pattern v4 uses combined with something at the runner
    side, but the action's swallowed errors make it untriagable from logs.

    Replacing the gh-pages checkout with `git clone --branch gh-pages
    --depth 1` using the same PAT, embedded in the URL. Direct, explicit,
    and if anything goes wrong the real git error reaches the log instead
    of "exit code 1". The recorded remote keeps the PAT, so the later
    `git push` reuses it — no separate auth setup needed.

    Identity config (user.name / user.email) moved into the clone step so
    it lives on the freshly cloned repo; dropped the now-duplicate config
    calls from commit-and-push.

    Source checkout still uses actions/checkout@v4 since it never had a
    problem (the default ref is the workflow's own commit, no wildcard
    refspec required).
2026-07-07 10:56:29 +02:00
maziggy 007c694674 ci(repo-stats): use jgehrcke's PAT for gh-pages checkout
The default GITHUB_TOKEN failed at `git fetch` for the gh-pages
    checkout step with opaque "exit code 1" and no surfaced git stderr,
    even with permissions: contents: write set at workflow level.
    actions/checkout's retry loop didn't recover.

    Switching the gh-pages checkout to secrets.GHRS_GITHUB_API_TOKEN —
    the same PAT jgehrcke/github-repo-stats already writes the branch
    with one step earlier — keeps the auth chain uniform and avoids the
    mismatch. persist-credentials defaults to true, so the subsequent
    commit-and-push step in the same gh-pages directory picks up the
    PAT automatically; no separate change to the push step needed.

    fetch-depth: 1 left explicit because checkout@v4 defaults to it but
    the value's load-bearing for this workflow (we only need HEAD of
    gh-pages, not history).
2026-07-07 10:55:30 +02:00
maziggy a48afbb6e7 ci(repo-stats): chart container pulls from ghcr.io alongside clones/stars
GHCR exposes total + 30-day daily-pull counts only in the package page
    HTML (no REST or GraphQL endpoint). jgehrcke/github-repo-stats has no
    notion of container metrics, so post-process the report after it runs.

    New: .github/scripts/ghcr_inject.py
    - Scrapes Total downloads (exact integer from title="N", not the K-rounded
      display) and the 30-day sparkline (rect data-merge-count, data-date).
    - Merges per-day rows into maziggy/bambuddy/ghcr-pulls.csv on gh-pages.
      Fresh window overwrites overlapping dates, so GitHub's late revisions
      to the last 30 days self-correct; days older than 30 stay frozen at
      whatever was captured while still in-window.
    - Patches latest-report/report.html: adds a TOC entry, a Container
      pulls (ghcr.io) section at the top, and a Vega-Lite line+point chart
      whose theme/config is cloned from the existing Total clones chart so
      it inherits the report's look-and-feel.
    - Bracketed by HTML-comment markers so re-runs replace rather than
      stack (jgehrcke regenerates report.html every tick; we re-inject).
    - Hard-fails if either scrape pattern stops matching — silent fallbacks
      would let the chart freeze without notice.

    Workflow: after run-ghrs, checkout source + gh-pages, run the injector,
    commit only if the diff is non-empty. Uses the existing contents: write
    permission; no new secrets.
2026-07-07 10:54:53 +02:00
maziggy c9061c2b72 fix(scheduler): cancel during queue dispatch actually cancels (#1853)
Symptom: user queued a batch of 10 prints, pressed Cancel on a pending
    row, the print started anyway. Repeated consecutively. Support bundle
    also showed 15x "sqlite3.OperationalError: database is locked" from the
    sensor history recorder in the same 8-minute window.

    Root cause is a check-then-act race in _start_print. check_queue takes
    a snapshot of pending items, then _start_print does FTP delete + FTP
    upload (5-30s) before the unconditional item.status = "printing";
    db.commit() at line 2792. /cancel commits status='cancelled' in a
    separate session during that window; the scheduler's stale in-memory
    write overwrites it and start_print ships. The lock-contention finding
    is the same shape from a different angle: _start_print did
    await db.flush() at line 2555 (after item.archive_id set + library_file
    delete) which opens the SQLite WAL writer lock and holds it through
    the FTP upload, queueing every concurrent writer behind it including
    the user's own cancel commit.

    Three guards layered:

    1) Atomic CAS at the pending->printing transition. UPDATE print_queue
       SET status='printing', started_at=NOW() WHERE id=:id AND
       status='pending'. rowcount==0 means user won; log abort, best-effort
       delete_file_async the file we just FTP'd up so it doesn't leak into
       the printer's BambuStudio file picker, send queue_item_failed WS
       event with reason="cancelled_mid_dispatch", return without calling
       printer_manager.start_print.

    2) Early db.refresh(item) + bail right after the printer connectivity
       check. Saves the wasted FTP upload when the row was already
       cancelled before _start_print resumed. Defense in depth; guard 1
       catches the same case at the CAS point.

    3) flush -> commit before the FTP block. The library-file-to-archive
       promotion's writes commit cleanly, WAL writer lock releases, sensor
       history and concurrent cancels stop queueing behind the scheduler.
       The flush-not-commit pattern was rolling back a pointer to an
       already-committed archive row, so the new behaviour matches reality
       (archive committed, pointer committed, FTP unblocked).
2026-07-07 10:53:39 +02:00
maziggy 82af3cc0fe fix(modal): gcode_injection checkbox toggles cleanly on single prints (#1852)
PrintModal carried a useEffect that reset scheduleOptions.gcodeInjection
    to false whenever mode === 'create' AND effectiveQuantity <= 1. The
    comment claimed the checkbox only renders for quantity > 1, but the
    actual render gate in ScheduleOptions is just hasGcodeSnippets — no
    quantity check. So with snippets configured + quantity = 1 (the OP
    scenario): user clicks the checkbox, React updates state to true,
    the parent's useEffect immediately sees effectiveQuantity <= 1 and
    resets to false, and the checkbox appears un-clickable. Edit-queue-
    item mode worked because mode !== 'create' short-circuited the reset.

    Drop the effectiveQuantity <= 1 clause from the reset. Keep the
    !settings?.gcode_snippets half as the legitimate cleanup for the
    "admin removes all snippets while modal is open" case. The scheduler
    reads item.gcode_injection per queue item regardless of batch size,
    so single prints can inject too.
2026-07-07 10:53:21 +02:00
maziggy 5a244661bc feat(scheduler): preheat & heat-soak before queued prints with per-filament chamber targets + airduct flap control (#1468)
New scheduler stage that heats the bed (and the chamber, on supported
    printers) and holds at temperature before each queued print starts —
    the heat-soak engineering filaments need for adhesion and warp
    control. Bambuddy waits between FTP upload and start_print, so the
    soak runs while the printer is otherwise idle. M191 is silently
    ignored by Bambu firmware, so doing this at the orchestration layer
    is the only place it works.

    Resolution order at dispatch:

    1. PrintQueueItem.preheat_override ∈ {inherit, on, off}.
       'off' skips entirely; 'inherit' falls back to the global
       preheat_enabled toggle; 'on' forces the stage even when the
       global is off.

    2. chamber_target = item.preheat_chamber_target_override
                     ?? max(filament_map[normalize(t.tray_type)] for loaded slots)
                     ?? 0.
       Mixed PA+PLA picks PA's 50 (max-across-slots — PA's chamber
       requirement is binding, PLA doesn't suffer being warm). PLA-only
       derives 0 and skips the chamber phase automatically.

    3. Three hardware tiers for chamber heat:
       - Active chamber heater (H2C/H2D/H2D Pro/H2S/X2D/X1E) → M141 +
         chamber-sensor wait
       - Chamber sensor only (X1C/P2S) → no M141, passive bed-radiation
         wait with hard max-wait cap
       - No chamber sensor (P1S/P1P/A1/A1 Mini) → bed + soak timer only

    4. Airduct flap (H2C/H2D/H2D Pro/H2S/X2D/P2S) auto-switches to
       match the chamber target — heating mode for engineering
       filaments, cooling mode for PLA. Bambu firmware does NOT
       auto-switch the flap with M141, so without this an ABS print
       on a previously-cooling flap fights the open exhaust, and a
       PLA print on a previously-hot flap recirculates ABS heat.
       Idempotent: only fires set_airduct_mode when current ≠ desired.

    Settings → Workflow → Queue & Dispatch → Preheat & Heat Soak card:
    master enable toggle (default off — disabled installs see no change),
    per-filament chamber-target editor (replaces a single global int that
    shipped in the first cut and couldn't serve PA + PLA in the same
    config), preheat_max_wait_seconds, preheat_soak_seconds. The Print
    Options panel in PrintModal gets a Preheat sub-section with the
    tri-state Inherit/On/Off control and an optional chamber-target
    override input.

    DB migration: PrintQueueItem gains preheat_override VARCHAR(10)
    DEFAULT 'inherit' and preheat_chamber_target_override INTEGER NULL.
    Idempotent via _safe_execute. Existing rows behave exactly as before
    the migration.

    Best-effort throughout: printer drops, refused M141 or set_airduct,
    missing bed temp, lost MQTT state mid-wait all log and return cleanly.
    Normal upload + start path runs after this returns regardless.
2026-07-07 10:52:50 +02:00
maziggy 2fe6982981 fix(hms): wrong-plate Ignore actually ignores + buttons read as buttons + ack-detection survives transient re-pause (#1869)
The HMS error modal had three compounding bugs that surfaced when a
    user forced a wrong-plate HMS (0500_8051) and tried to dispatch the
    per-fault actions.

    (1) IGNORE_RESUME did not ignore. Bambuddy redirected the action on
    state=PAUSE to a plain `resume` command, citing a #1830 verdict that
    BambuStudio's "err-bearing shape" was firmware-silently-rejected.
    BambuStudio source disagrees: DeviceErrorDialog.cpp:600 dispatches
    IGNORE_RESUME via command_hms_ignore, whose wire shape is
    {command:"ignore", err:"<decimal>", param:"reserve", job_id:...}.
    That's a distinct command from `resume` — the firmware suppresses
    the next re-check AND auto-resumes in one operation. Plain resume
    means "re-check normally", which is exactly why the wrong-plate
    detection re-fired 1-2 s after the user clicked Ignore. The #1830
    "err-bearing shape rejected" test almost certainly sent the err as
    a hex shortcode; BambuStudio passes std::to_string(int m_error_code)
    i.e. the DECIMAL form, which is what the firmware matches against.

    (2) Action buttons read as inert badges. The button className used
    `hover:${buttonHoverColor}` — a template-literal interpolation
    Tailwind's JIT scanner can't see as a literal string, so the
    per-severity hover utility never reached the compiled CSS. Same
    bg/text color as the severity badge above and no border made it
    read as another label. No disabled state and no spinner during the
    2.5 s ack wait left clicks sitting silently inert.

    (3) Ack-detection 502'd on legitimate ack. The route compared
    (gcode_state, hms_errors-len) before vs after publish; wrong-plate
    re-pause round-tripped both fields to their pre-publish values
    inside the 2.5 s window → false 502 even though the firmware fully
    ack'd. PROBLEM_SOLVED_RESUME working but IGNORE_RESUME 502'ing on
    the same fault was the same race resolving differently.

    Fixes:

    bambu_mqtt.py — new hms_ignore_command() publishes the BambuStudio
    shape; existing hms_ignore(persistent) renamed to hms_idle_ignore
    (unchanged shape, used by NO_REMINDER_NEXT_TIME per
    DeviceErrorDialog.cpp:588). Dispatch routes IGNORE_RESUME,
    IGNORE_NO_REMINDER_NEXT_TIME, and DONT_REMIND_NEXT_TIME to
    hms_ignore_command (BambuStudio routes all three to the same
    command_hms_ignore — the "don't remind" half is the firmware's
    job). NO_REMINDER_NEXT_TIME stays on hms_idle_ignore type=0. Hex →
    decimal err conversion at the helper layer with a defensive
    fallback. job_id=None → empty string (matches BambuStudio's
    std::string default).

    HMSErrorModal.tsx — getSeverityInfo loses the dead buttonHoverColor
    field. Action button uses static
    `bg-white/10 hover:bg-white/20 active:bg-white/30 text-white
    border border-white/20`, wires
    `disabled={!hasPermission||mutation.isPending}`, and renders
    `<Loader2/>` only on the button whose (action,print_error) matches
    mutation.variables.

    printers.py — ack-detection probes `client._last_message_time`
    (bumped on every MQTT push regardless of payload) rather than
    diffing state fields. The pushall that follows every command
    guarantees a fresh push lands inside the 2.5 s window on any
    healthy printer; only firmware-silent-drop leaves the timestamp
    untouched, which is the 502 path #1830 wanted.
2026-07-07 10:52:19 +02:00
maziggy 6507fbcc40 fix(slicer): surface real CLI rejections + hard-skip mismatched filaments in auto-pick (#1851)
Two compounding bugs let an H2C-bound filament land in slot 1 of an A1
    slice silently. (1) `_slicer_rejection_message` discarded the actual CLI
    diagnostic - `filament preset Generic PLA @BBL H2C (slot 1) is not
    compatible with printer Bambu Lab A1 0.4 nozzle.` - when the sidecar's
    headline error_string was Bambu Studio's catch-all
    `The input preset file is invalid and can not be parsed.` placeholder.
    The real reason was in the stdout `[error] run NNNN:` line, trimmed off
    before reaching the SliceJob's error_detail. (2) `pickFilamentForSlot`
    used a soft `-100` mismatch penalty rather than a hard skip, leaving
    the "never auto-fill an incompatible preset while a compatible one
    exists" contract implicit. The unused-slot substitution in
    `substitute_unused_plate_filaments` then propagated whatever slot 1
    held across every unused slot - one bad pick poisoned the array.

    (1) Mine `[error] <msg>` (with or without `run NNNN:`) from the full
    pre-trim response; substitute the placeholder, keep meaningful
    headlines. (2) Partition candidates into compatible/unknown vs
    mismatch; prefer compatible whenever the bucket is non-empty, fall
    back to mismatch only on graceful-degrade. Picker helpers moved out
    of `SliceModal.tsx` into `utils/slicePresetPicker.ts` so the modal
    file stays component-only (react-refresh lint).
2026-07-07 10:51:51 +02:00
maziggy 7f661be941 fix(frontend/hms): surface uncataloged HMS faults that carry firmware actions (#1840)
filterKnownHMSErrors and the modal-local copy gated visibility on
    ERROR_DESCRIPTIONS membership. H2C 0500_809C carries IGNORE_RESUME /
    PROBLEM_SOLVED_RESUME but is missing from the bundled 853-entry catalog,
    so the entire error — pip, count, panel, action buttons — never rendered
    even though backend captured + dispatched it correctly.

    The gate isn't dead code: PrintersPageBucketing pins the post-cancel
    0C00_001B junk-echo regression to it. Widen the predicate to keep
    (cataloged) OR (actions.length > 0) so noise is still filtered out
    while user-actionable faults always surface.

    Replace the modal's inline filter with the shared helper so badge
    counts and modal contents agree by construction. Fall back to
    hmsErrors.unknownCode ("Unknown HMS code — see the Bambu Lab wiki
    for details.") when the catalog has no entry. New key translated in
    all 11 locales.

    New bucketing test pins PAUSE + uncataloged-with-actions = error;
    existing FAILED + uncataloged-without-actions = finished stays green.
2026-07-07 10:51:17 +02:00
maziggy a57b752e3a fix(notifications): defer first-layer photo until printer is actually printing (#1837)
P1S and other Bambu printers tick layer_num during the pre-print calibration
    sequence (homing -> auto bed leveling -> bed-surface scan -> nozzle clean ->
    purge / wipe), so a bare `2 <= layer_num <= 5` gate fires the first-layer
    notification minutes before the first real extrusion. The attached photo
    shows a lowered bed, parked toolhead, and a clean plate -- exactly the
    state during PREPARE, not after layer 1.

    The reporter's log timeline made it explicit:
    - 13:54:27  PRINT START detected
    - 14:10:13  [SNAPSHOT] Capturing fresh frame  (notification fires here)
    - 14:44:28  gcode_state: RUNNING (debug log, only visible because they
                enabled debug logging mid-print)

    So the notification went out ~30 minutes before the print actually started.

    Fix in main.py:6043 -- the on_layer_change first-layer block now requires
    both:

      - state.state == "RUNNING" (gcode_state is RUNNING, not PREPARE)
      - state.mc_print_sub_stage in (None, 0)
        (0 = "Printing" in the canonical STAGE_NAMES at bambu_mqtt.py:376;
        None preserved as a no-opinion fall-through for any firmware that
        doesn't push the sub-stage so unknown-firmware installs keep
        their existing behaviour)

    _first_layer_notified is only set after the gate passes, so calibration
    ticks are non-consuming -- the next on_layer_change edge fires the
    notification once the printer is actually printing.

    The trigger window widens from [2, 5] to [2, 10] so that if calibration
    consumed several layer_num slots before RUNNING, the deferred edge
    still falls inside. The RUNNING + sub-stage gate ensures we don't fire
    on a stale layer count.
2026-07-07 10:50:15 +02:00
maziggy b26b68c236 fix(permissions): self-heal Administrators to ALL_PERMISSIONS on upgrade + Pipelines runs dashboard polish
Administrators system group sync
    - Fresh installs already bootstrap with ALL_PERMISSIONS, so they always have
      every permission. Upgrades previously only got what one-off backfill blocks
      in seed_default_groups() explicitly listed (library:purge, archives:purge,
      the OWN/ALL read-flag block, orca_cloud:auth, pipelines:*). Any Permission
      enum member added without a matching block silently stayed missing on
      existing admin rows. The most recent gap was printer_sensor_history:read
      (Sensor History charts returned 403 for upgraded admins).
    - seed_default_groups() now syncs Administrators to ALL_PERMISSIONS on every
      startup: append every Permission value that isn't already on the row.
      Additive only -- hand-added custom permissions are preserved.
    - The pure-admin one-off backfills (library:purge / archives:purge block,
      the OWN/ALL + orca_cloud:auth + legacy-read-flag block, the Administrators
      branch of the pipeline backfill) are retired since the sync subsumes
      them. Non-admin backfills (Operators / Viewers OWN-tier reads, Operators
      orca_cloud:auth, pipelines for non-admin groups, makerworld:*, clear_plate
      cross-group adders) are untouched.
    - Tests: test_administrators_printer_sensor_history_read_backfilled
      (regression for the reported gap),
      test_administrators_sync_covers_every_current_permission (generic
      invariant -- any future new permission lands on admin without needing
      a one-off test), test_administrators_sync_is_additive_only (custom
      permissions preserved). 12/12 backfill-migration + 102/102 broader
      permission tests green; ruff clean.

    Pipelines runs dashboard
    - PipelineRunsPage.tsx: the Pipeline / Status / Target filter row's three
      native <select> elements are replaced with a bambu-themed FilterDropdown
      (button trigger, floating menu, optgroup-style headers for the Target
      picker, hover + selected states with a check mark, closes on outside
      click and Escape). Same value/onChange contract -- visual only.
    - SlicerPipelinesPanel.tsx: wrap list?.pipelines ?? [] in useMemo so the
      reference is stable when the data is stable. Fixes the
      react-hooks/exhaustive-deps warning where the inline fallback returned
      a fresh empty array every render, invalidating both downstream useMemo
      caches (target-options + filtered-pipelines list).
2026-07-07 10:49:40 +02:00
maziggy 917bfd7666 feat(labels): scannable QR on 203 dpi thermal printers + monochrome mode (#1870)
The 40x30 mm box label rendered its QR too densely for low-res thermal
printers — the modules bled together and wouldn't scan. Two causes: the QR
was 20% of inner width (~7.5 mm on the narrowest template, half of the
others) and used ERROR_CORRECT_M. Fix adaptively so all templates benefit:
give the roomy-layout QR a 12 mm minimum size (box_40x30 -> 12 mm, ~3.5
dots/module at 203 dpi) and switch label QRs to ERROR_CORRECT_L (same
payload, chunkier modules; a label needs no M-level recovery). Keep the
quiet-zone border at 2 — the size+L gains suffice without risking scans.

Also add a Monochrome (black & white printer) option to the label dialog:
drops the colour swatch (a useless grey block on B&W) and widens the text;
the hex-code line still carries the colour. Threaded through the renderer,
route, API client, and modal, with translations in all 11 locales.
2026-07-07 10:31:19 +02:00
maziggy 45f0ba47db fix(scheduler): default require_plate_clear to False to match schema/UI (#1865)
check_queue() read the plate-clear setting with _get_bool_setting(default=True),
but SettingsSchema.require_plate_clear defaults False and the whole frontend
treats a missing value as off. Since _get_bool_setting returns its default when
no DB row exists, installs that never saved the setting enforced the plate-clear
gate the UI showed as disabled — FINISH-state printers never dispatched and no UI
control existed to clear awaiting_plate_clear. Read the setting with default=False
so the enforced behavior matches the schema and the toggle. Both defaults shipped
together in #752; this aligns them.
2026-07-07 10:07:47 +02:00
maziggy 1d344a8536 fix(ui): resolve light-theme low-contrast semantic text app-wide (#1909)
The app was built dark-first, so hundreds of hardcoded Tailwind semantic
text/icon utilities at light shades (text-amber-400, text-blue-300, ...) had
no dark: variant. With darkMode:'class' they applied in light theme too,
producing washed-out text on pale tints and white cards — including the three
reported spots (AMS Drying banner, Archives no-3MF warning, debug-logging
banner). Give each a theme-aware pair: a darker readable shade in light theme
with the original pinned to dark:, so dark theme is unchanged. ~100 files.

The bambu-* CSS-variable palette (self-correcting) and the dark-only SpoolBuddy
kiosk are left untouched. Plain text-white is already theme-aware via the
existing index.css .text-white override, so it needed no changes.
2026-07-07 09:54:10 +02:00
maziggy 4af9da26f9 fix(sponsor): anchor 14-day toast cooldown on show, not just on CTA click (#2477)
The sponsor toast re-fired on every fresh browser session. The backend
owns the 14-day cooldown but only persists the anchor (last_shown_at) and
the seen-milestone record inside POST /sponsor-prompt/dismiss, and the hook
only called dismiss from the "View supporters" CTA onClick. A user who saw
the toast but never clicked the CTA persisted no state; the per-tab
sessionStorage guard hid the re-fire within one session, but every new
session re-checked against empty state and re-showed the same milestone.

Record the toast as shown the moment it renders (POST /dismiss right after
showPersistentToast) so display is what arms the cooldown. CTA click stays
optional and just navigates. Frontend-only; backend cooldown logic unchanged.
2026-07-07 08:30:58 +02:00
maziggy 90a7d68d4a fix(windows): bundle vcruntime140_1.dll so greenlet loads on fresh Win10 (#2474)
A clean Windows 10 install crashed on startup: init_db() -> SQLAlchemy async
engine -> greenlet failed with "DLL load failed while importing _greenlet:
The specified module could not be found", so uvicorn never bound :8000 and the
dashboard refused all connections while the NSSM service still showed running.

greenlet's _greenlet.pyd is C++ and needs vcruntime140_1.dll, which the
python.org embeddable distribution does not ship (it includes only
vcruntime140.dll, enough for the pure-C python313.dll). Machines with the VC++
2015-2022 redistributable already installed have the DLL in System32, which
masked the bug in testing.

Stage vcruntime140_1.dll and msvcp140.dll next to python.exe at build time,
from a vendored copy or the runner's System32, failing loudly if absent. The
Inno Setup [Files] step already copies staging\python\* recursively.
2026-07-07 07:50:08 +02:00
maziggy af867c0392 fix(vp): populate bind-interface list on macOS (route non-Linux to psutil)
get_network_interfaces() only sent Windows to the psutil path; macOS fell into
the Linux ioctl branch, whose SIOCGIFADDR/SIOCGIFNETMASK ioctls are Linux-only.
macOS/BSD have fcntl but different ioctl numbers, so every call raised OSError
and the function returned an empty list — the VP bind-interface dropdown showed
nothing. Route all non-Linux platforms through the cross-platform psutil path.
2026-07-06 13:03:22 +02:00
maziggy 2527a9820d fix(install): make macOS native install rootless (brew + venv permission errors)
macOS mixed root-only steps (default /opt path, sudo git clone) with steps
that must not run as root: brew refuses to run as root, and a root-owned
venv/node_modules can't be managed by the launchd agent. The installer now
refuses sudo on macOS, defaults to ~/bambuddy, and drops sudo from the
download/venv/frontend/env/dir steps. A --path under a root-owned parent still
works via a single elevate-and-chown. Linux (service user + systemd) unchanged.
2026-07-06 12:59:22 +02:00
maziggy 379765a46a fix(camera): transcode non-JPEG external snapshots to JPEG (#1902)
External cameras in HTTP-snapshot mode failed to load with a repeating
"connection lost" when the endpoint served PNG/WebP/BMP stills instead of
JPEG (common on IP cameras and reverse-proxied snapshot URLs). The URL
rendered fine directly in a browser, but Bambuddy's MJPEG stream wraps
every part in a hard-coded Content-Type: image/jpeg boundary, so a
non-JPEG payload labelled as JPEG made the browser reject the frame and
tear down the whole multipart/x-mixed-replace stream.

_capture_snapshot now transcodes non-JPEG stills to JPEG via OpenCV
(already a dependency). Genuine JPEG snapshots keep a byte-for-byte fast
path; truly undecodable responses (HTML error pages, auth redirects) fall
back to the previous raw-return behaviour with a single clear warning
instead of a per-frame log flood.
2026-07-06 07:54:30 +02:00
maziggy a82eeff483 fix(ui): restore missing per-user Notifications nav item (#1901)
The sidebar-ordering refactor in #1673 accidentally dropped the
`notifications` entry from `defaultNavItems` and its
`notifications:user_email` permission mapping, but kept the advanced-auth
visibility gate that references that id. With no nav entry the id never
enters the render set, so the /notifications page (route, page, and API
all intact) became reachable only by typing the URL — users could no
longer opt in/out of their own print email notifications from the menu.

Restore both the defaultNavItems entry and the permission gate, matching
the permission the user-email-preferences API actually requires
(notifications:user_email, held by both default groups). Add comments so
the entry isn't dropped again in a future sidebar refactor.
2026-07-06 07:38:44 +02:00
maziggy deb58b4ff1 Updated BACKERS 2026-07-05 10:45:05 +02:00
maziggy f3450e60fd Updated BACKERS 2026-07-05 10:44:31 +02:00
maziggy 7d4dfd5a7d fix(vp): stop uvloop from silently truncating VP FTP uploads (#1896)
Native (non-Docker) installs launched uvicorn without --loop asyncio, so
uvicorn[standard] auto-selected uvloop. uvloop's SSL layer drops
already-received but still-buffered data when the client closes the data
connection without a TLS close_notify while the reader is flow-control
paused on slow storage. cmd_STOR writes each chunk to disk inside the read
loop, so a slow consumer falls behind, the tail is lost, read() returns a
clean EOF, and the loop exits with no exception -- the server acked 226 for
a file it truncated itself, then archived, queued, and forwarded the corrupt
3MF to the real printer.

Fix in two independent layers:

1. Remove the trigger: add --loop asyncio to every native launch path,
   matching the Dockerfile -- deploy/bambuddy.service, install/install.sh
   (systemd + launchd), spoolbuddy/install/install.sh, the Windows NSSM
   service, README, CONTRIBUTING dev command.

2. Defense in depth (loop-independent): cmd_STOR now validates that a
   received .3mf opens as a ZIP (reads the central directory, no
   decompression) before replying 226. A truncated/corrupt file is dropped
   and answered with 426, and on_file_received never runs -- so a broken
   upload surfaces as an immediate slicer-side send error instead of being
   archived and pushed to the printer. Scoped to .3mf; other filetypes pass
   through unchanged.
2026-07-05 10:32:13 +02:00
maziggy 168d9d8f8e fix(auth): let API keys manage projects via new can_manage_projects scope (#1893)
PROJECTS_CREATE/UPDATE/DELETE were in _APIKEY_DENIED_PERMISSIONS with no
entry in _APIKEY_SCOPE_BY_PERMISSION, so every project mutation returned a
generic 403 for any API key regardless of granted permissions -- the same
regression class as archives (#1888) and library (#1832).

Add a per-key can_manage_projects scope. Project routes gate on plain
PROJECTS_* (no OWN/ALL split), so all three CRUD permissions map to the one
scope; membership edits (add-archives) gate on PROJECTS_UPDATE and are
covered. PROJECTS_READ is unchanged (already under can_read_status).

Column defaults TRUE for new keys; existing rows backfill to FALSE so the
upgrade never silently widens scope. Migration is BOOLEAN (SQLite + Postgres
safe), verified on fresh SQLite and Postgres 17. Bundled SpoolBuddy kiosk key
set to False. Settings API-key UI gets a Manage Projects toggle + Projects
badge; 11-locale i18n. RBAC scope matrix + drift guards extended.
2026-07-05 09:58:16 +02:00
maziggy 53ae5fb620 fix(drying): don't stop a running AMS dry on an unreliable humidity re-check (#1892)
Auto-drying stopped manually started (and pre-restart) AMS drying cycles
after exactly 30 minutes. The already-drying branch in _check_auto_drying()
applied a humidity-based auto-stop despite its own "track but don't stop"
comment, and the humidity re-check is unreliable: RH drops steeply in heated
air, so the sensor reads ~15-20% within minutes of the dryer starting even
with saturated filament. humidity <= threshold was thus effectively always
true, and the _min_drying_seconds=1800 floor pinned the stop to the 30-minute
mark. This also truncated Bambuddy's own preset-duration dries.

Remove the humidity-based early-stop entirely: a running dry now runs to its
configured duration (firmware stops it). Scheduling stops (print priority,
queue no longer needing the dry) are unchanged via _stop_drying(). Drop the
now-unused _min_drying_seconds.
2026-07-05 09:32:02 +02:00
maziggy e9cddc544a fix(websocket): stop the ws-token reconnect loop on auth failure
After the GHSA-r2qv gate (b7d7c825), /api/v1/ws needs a token from
POST /api/v1/auth/ws-token (Permission.WEBSOCKET_CONNECT). When the mint
failed, useWebSocket swallowed the error, opened a tokenless socket, the
server closed it 4401, and ws.onclose rescheduled connect() every 3s -
an endless loop that hammered /auth/ws-token. The dominant trigger is a
validly-logged-in user whose group lacks WEBSOCKET_CONNECT (mint returns
403). A secondary leak: the unmount-triggered onclose could schedule a
post-unmount reconnect.

Classify the mint failure: 401 (JWT expired; request() already clears it
and dispatches auth:expired) or 403 (valid session, missing permission;
degrade to REST polling) now stop the hook - no tokenless socket, no
reconnect. A 4401 close is terminal. Network/5xx still reconnect. A
disposedRef set in cleanup before close() prevents the unmount-race
reconnect. Same 401/403 no-open guard applied to StreamOverlayPage.

Also surface a one-line hint under the WebSocket permission in the group
editor (all 11 locales) explaining that live updates need it and fall
back to polling without it - rather than auto-granting the permission,
which would partly undo the GHSA-r2qv gate.
2026-07-05 09:12:42 +02:00