mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
9ef03067f734a81dc20bd54cbeee4c3f27f2f41d
328
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
5a67dffe4f |
fix(timelapse): poll longer, diff without a clock, delete once archived (#2704)
Timelapse was on, the video never reached the archive, and Scan for Timelapse found nothing afterwards. Across 247 support bundles this was the norm, not an edge case: 457 automatic scans scheduled, 262 attached. The scan looked four times over ~65s. The attempt that found the video was #1 272 times, then 17 / 13 / 13 — flat against the cutoff, not decaying, i.e. files were still arriving when we stopped. What ran afterwards searched for the print name inside the filename; Bambu only writes "video_<timestamp>", so it fired 159 times and matched zero. The manual Scan had no baseline at all and matched on filename timestamp, FTP mtime, or "there is only one video" — all reading a clock a LAN-only printer cannot sync. The reporter's P1S was six and a half days out. - Poll for minutes instead of ~65s; drop the name-match fallback. - Persist the print-start baseline on the archive, so the diff survives a restart mid-print and the manual Scan runs the same comparison. With a baseline present the clock-based strategies are skipped entirely — they can only turn an honest "pick one" into a confident wrong answer. - When several files are new (a previous print's video landing late), exclude the ones already attached to another archive instead of ordering the candidates. Ordering could only be done on the printer's clock. - Delete the video from the printer once archived. Keeps /timelapse to unclaimed files, which is what makes the diff unambiguous, and stops P1S cards filling with AVIs. - Gate that delete on a verified transfer: download_file now compares against the size from the listing. An FTPS connection closing early does not always raise, so a partial buffer was being attached as a complete video — which would also have been the one case where deleting the source lost data. Bounded twice on purpose: wall-clock deadline plus a derived round cap, since the deadline stops bounding the loop as soon as the sleeps are shortened. Per-round logging only speaks when the listing changed — 31 rounds of full listings would bury the interesting line in the support bundle. Migration adds print_archives.timelapse_baseline as JSON, spelled the same on both dialects so a migrated database matches a fresh one. ----------- fix(finish-photo): add the timelapse frame to the archive after the notification (#2704) When a print records a timelapse, its last frame is the better finish photo: the firmware stops recording with the toolhead parked and before the end G-code drops the bed, where a live grab at that moment catches a lowered plate. Bambuddy waited 60s for the video and then gave up, because the print-complete notification blocks on that photo and holding a notification for minutes is worse than sending it with the live grab. P1-series printers write MJPEG AVI rather than H.264 MP4 and serve it slowly. Measured over 261 attaches in the support bundles: P1S median 33s, p90 167s, worst 546s, while every other model finished inside 26s. So the printers that most needed the better framing were the ones that never got it. Keep the notification on the same bound, and keep waiting off to the side. _capture_finish_photo_from_timelapse now reports whether it ran out of time or concluded — a video that landed and failed extraction is not worth retrying, one that never arrived is. On the first, schedule a background task that waits up to 15 minutes and inserts the extracted frame at the front of the archive's photo list, where the gallery opens. The live grab stays on disk: the notification already links to that exact file, so removing it would leave a broken image in Discord or Telegram. The length check proves we received what the listing said, not that the file was finished. The first look happens ~5s after the print ends, while the printer may still be writing, so a growing file can be listed short, served short, and pass. Re-list after the download and only accept the video once its size has stopped changing — a failed re-list counts as not settled, since "could not check" must not mean "safe to delete". |
||
|
|
8fd1f884dc |
feat(mqtt): publish the plate-clear gate and add a notification for it (#2525)
When a print reaches a terminal state Bambuddy holds the queue until
someone confirms the build plate is clear. That gate was visible only in
the Web UI: the printer's own MQTT push reports nothing beyond RUNNING,
PAUSE, FAILED, FINISH and IDLE, so an external automation could not tell
"finished" from "finished and still waiting for a human".
The per-printer status topic now carries an awaiting_plate_clear field,
and every transition is additionally published on a new retained topic,
bambuddy/printers/{serial}/plate_clear. Retained, and published from the
flag itself rather than from printer telemetry: a subscriber learns the
state of every printer the moment it connects, and the state stays
correct after Auto Off powers a printer down - telemetry stops there,
which would otherwise leave the status topic frozen at false.
Publishing is edge-triggered. The queue clears the gate on every
dispatch whether or not it was up, and no subscriber should see a
"plate cleared" for a plate that was never dirty. Persistence and the
WebSocket broadcast stay unconditional; they are idempotent and predate
this.
A matching Plate Clear Required notification event was added, off by
default on every provider because it fires after every print at the
same moment as the print-complete alert. Only the rising edge notifies.
Acknowledging still goes through POST /printers/{id}/clear-plate.
Two tests in test_printer_manager_status_broadcast.py asserted
_schedule_async.call_count == 2 for the setter. The new emission makes
it three on a transition, so they now assert that the persist and
broadcast coroutines are actually scheduled - which is the contract
Translated in all locales; wiki updated. Covered by backend and
frontend tests.
|
||
|
|
2e74f2ad41 |
feat(ams): confirm spool assignments landed instead of fire-and-forget (#2582)
Assigning a spool to an AMS tray pushed ams_filament_setting + extrusion_cali_sel and reported success immediately, whether or not the tray accepted it. A silently-dropped assignment never surfaced, and since a print only deducts from the spool on the exact tray it pulls from, it also recorded no filament usage - which made the whole thing feel random. Read the AMS telemetry back after every assign (inventory assign_spool and the Configure Slot modal) and toast the outcome: loaded when the tray echoes the pushed tray_info_idx, a warning when the filament loaded but the K-profile (cali_idx) did not, or not-confirmed after ~30s. Verification uses the periodic per-tray push (the command ack hardcodes sequence_id 0 and can't be correlated); an on-demand pushall is nudged so it lands quickly. Covers regular AMS, AMS-HT and external slots; stays silent rather than inventing a failure if the printer goes quiet. The read-back check runs on every AMS push because the change-hash excludes tray_info_idx. |
||
|
|
258db95483 |
fix(overlay): authenticate the OBS overlay with a token when login is enabled (#2613)
The /overlay/{id} route renders without a login, but everything it draws is
auth-gated: printer status and name (PRINTERS_READ), one setting (SETTINGS_READ),
and the camera stream (a camera-stream token). A signed-in browser rides its JWT
from local storage; OBS is a fresh browser with no session, so the overlay stayed
blank whenever authentication was enabled. Cloudflare/remote access was never the
cause -- an incognito window fails identically.
Give the overlay a self-contained kiosk-token mode, mirroring the Cam Wall:
- New `overlay` long-lived-token scope, kept separate from `camwall`: the overlay
names the printed file on screen, which a Cam Wall token is trusted never to
expose, so folding it in would silently widen every existing wall token.
- New token-authed GET /printers/{id}/overlay-status returning exactly the fields
the overlay draws and nothing else; added to the auth-middleware allowlist so it
reaches its own RequireOverlayTokenIfAuthEnabled gate.
- StreamOverlayPage reads ?token= and, in that mode, authenticates its status and
camera calls with the token and skips the WebSocket (the 2s poll is the feed).
The logged-in path is unchanged.
- Token-mint UI (Settings > API Keys) offers the scope with a ready-made
/overlay/{id}?token= URL copied once on creation.
|
||
|
|
4436349a03 |
fix(stats): scope per-run filament to the printed plate, not the whole 3MF (#2614)
A single plate dispatched from a multi-plate 3MF could log the entire file's filament against that one plate. When the AMS tracker measured nothing, a completed run's PrintLogEntry.filament_used_grams fell back to PrintArchive.filament_used_grams -- the sum over every plate (correct for the archive card / project rollup, #1593) -- ignoring the archive's plate_id. So each printed plate of a 22-plate file logged the full ~12 kg; cost inherited the same whole-file value. Forward: when the archive has a plate_id and its 3MF is on disk, the completed- run fallback uses that plate's own slicer estimate (extract_plate_metadata_from_3mf) and scales cost by the plate's share of the whole. Tracker-measured runs and single-plate archives are unchanged. Backfill: a startup migration repairs rows already written -- completed entries whose stored grams exactly equal the archive's whole-file value, with a plate_id and an on-disk 3MF, get recomputed to plate-scoped grams + cost. The exact-match guard never touches tracker-measured or partial rows; idempotent, data-only, identical on SQLite and Postgres, and logs the correction. |
||
|
|
5afdaa83d1 |
fix(db): configurable pool, auth_enabled cache, single-checkout auth (#2572)
Two or three concurrent UI logins exhausted the PostgreSQL pool on the reporter's 93-printer farm: QueuePool limit of size 10 overflow 20 reached, with all 30 sessions idle in transaction on the auth_enabled SELECT. Three regressions had landed on dev after an earlier configurable-pool change was reverted and never re-applied (only the route-by-route session fixes were). - Pool sizing is env-configurable again (DB_POOL_SIZE / DB_MAX_OVERFLOW / DB_POOL_TIMEOUT / DB_POOL_RECYCLE); the PostgreSQL default returns to 20 + 80 with pool_pre_ping and pool_recycle=1800, and GET /api/v1/system/db-pool reports resolved config + live gauges without checking out a connection. SQLite unchanged (20 + 200). - is_auth_enabled caches for 30s again. Only enabled=True is ever cached, so a stale read can only fail closed (require auth), never open; set_auth_enabled invalidates immediately. An autouse test fixture resets the module cache between tests to keep ordering deterministic. - Every authenticated request checked out two pooled connections: the permission dependency held one and the revoked-jti check opened another. is_jti_revoked now reuses the caller's session; the token dependencies and the auth-middleware gateway were restructured to open one session and pass it in, so each request makes a single checkout. |
||
|
|
8d618678a2 |
fix(stats): don't bank the disconnect gap as print time on reconnect (#2592)
reconcile_stale_active_prints closes out stale status="printing" archives by synthesising an aborted on_print_complete, which logged a PrintLogEntry whose duration was completed_at - started_at — the whole multi-day disconnect gap, since a reconciled archive's real end time is unknown. Across a farm of stale rows this inflated Total Print Time by hundreds of hours, and the Stats total recomputed the same value from the timestamps even when duration was NULL/0. Reconciled completions now log duration_seconds=0, the two Stats time paths trust a stored 0 instead of recomputing, and reconciled aborts get an honest "Stale - reconciled ..." failure_reason instead of "User cancelled". Genuine long prints are untouched (no cap; still-running >24h prints aren't stale). |
||
|
|
b8cd1ab22d |
fix(cover): release the DB connection before the FTP thumbnail download (#2572)
GET /printers/{id}/cover took its printer row via Depends(get_db), whose
yield-dependency session stays open for the whole request — including the
3MF cover download (up to 8 remote paths x retries with backoff, minutes
under FTP contention). One pooled connection sat idle-in-transaction the
entire time; on a large farm a wall of dashboards drained the pool. The
route now fetches the printer in a short-lived async_session() and releases
the connection before the download (expire_on_commit=False keeps printer.*
readable). Pinned by a signature-inspection guard that fails if get_db is
ever re-added.
fix(print-start): release the DB connection across plate detection and 3MF download (#2572)
on_print_start held one session from top to bottom of the handler, across
two slow I/O blocks that need no database: the plate-detection camera grab
and, on the new-archive path, the multi-path 3MF FTP download (its own
comments cite worst cases of tens of minutes). The connection sat idle-in-
transaction for both, once per starting print. It now commits at each
boundary — only read SELECTs have run on those paths (every write branch
returns earlier), so the commit persists nothing and simply returns the
connection to the pool for the I/O; the next query re-acquires, and
expire_on_commit=False keeps printer.* readable.
fix(startup): connect to printers concurrently so the API serves within seconds (#2572)
init_printer_connections awaited each printer's connection serially, and
connect_printer ends in a fixed 1s settle wait. The MQTT connect is non-
blocking (connect_async + loop_start), so that 1s x fleet size was pure
serial dead air the FastAPI lifespan blocked on before uvicorn began
serving — ~100s before port 8000 responded on a 93-printer farm. The
connections are now started with asyncio.gather, so the step takes ~1s
regardless of fleet size. return_exceptions=True isolates each result: one
unreachable printer no longer aborts the rest, or startup itself.
|
||
|
|
00251fe808 |
feat(orca-cloud): pair via RFC 8628 device flow, replacing the paste-based sign-in
OrcaSlicer shipped a first-class external-app pairing API (OAuth 2.0 Device Authorization Grant), so the Supabase-PKCE copy-paste flow is replaced end to end. Connecting is now: click Connect, approve a short code on the Orca Cloud settings page, done — no redirect, no callback paste, no client secret, works from a LAN IP / localhost / behind a proxy. Backend: services/orca_cloud.py rewritten to device-code request + poll (the four RFC outcomes) + refresh_token grant + introspection + external sync pull; routes expose /device/start and /device/poll (device_code kept server-side in the reused orca_cloud_pending_* columns, no migration). Requests sync:read (read-only feature). Prod endpoint by default, ORCA_CLOUD_API_BASE overrides to staging. Wired the shared httpx client (fixes a per-request socket leak). Frontend: device-code connect UI + api client methods; all 11 locales updated. |
||
|
|
da128e50c7 |
fix(inventory): broadcast assignment change when auto-unlink clears a stale slot (#2575)
The #2575 reconciliation correctly deletes a stale external-spool assignment in on_ams_change, but did so silently: spool_assignment_changed was only broadcast by the manual REST assign/unassign endpoints, and the frontend's spool-assignments cache is invalidated only by that event. So after an external-spool type swap the DB was correct but every open browser kept rendering the unlinked spool on the slot until an unrelated refetch — which the reporter read as "the fix didn't work" (a browser refresh showed the right state all along). Broadcast spool_assignment_changed for each auto-unlinked slot after the commit. No frontend change — the handler already invalidates the cache. |
||
|
|
a5176349dd |
fix(notifications): capture the snapshot outside the DB session (#2572)
The progress-milestone and HMS-error notification paths in on_printer_status_change held a session across the ~15s camera snapshot taken for the notification image, pinning a pooled connection per milestone/error per printer. The snapshot needs no DB: read the printer in a short session, release it, grab the snapshot with none held, then open a fresh session for the notification send (and lift the db-free MQTT publish out too). Pinned by a test that fails if the snapshot runs while a session is open. |
||
|
|
c1d25af7f1 |
fix(finish-photo): release DB connection during the camera capture (#2572)
The background finish-photo task held one session open across the whole capture pipeline (timelapse extraction, up to 20s stage-22 wait, external-camera/RTSP grab) — tens of seconds of a pooled connection idle-in-transaction per finishing print. Read the setting/printer/archive in a short session, release it, run the capture with no session held, then re-open a fresh short session only to append the photo. Logic unchanged. |
||
|
|
afcea50eb7 |
fix(timelapse): don't hold a DB connection across the FTP scan (#2572)
_scan_for_timelapse_with_retries opened one session and held it across the FTP directory listing and the multi-MB video download — once per retry attempt, per completed print — pinning a pooled connection idle-in-transaction for the whole transfer. Read the archive + printer in a short session, release it, do the FTP list/download with no session held, then re-open a fresh short session only to attach the file. Existing scan tests already cover the read/download/attach path. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
d568307eac |
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. |
||
|
|
b5a2f56cca |
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.
|
||
|
|
4bbf0f031e |
feat(slicer): Pipelines — archive entry point + slicer progress toast (#1425 PR B follow-up)
Two real gaps from the PR B drop:
1. Run-with-pipeline only existed in the file manager. Operators who keep
working files in archives had to copy them to the library to use a
pipeline.
2. Triggering a slice via a pipeline produced a silent multi-second-to-
minute wait. The manual SliceModal flow shows the sticky
"Slicing X - Generating G-code 75%" persistent toast; the pipeline
path went through asyncio.create_task directly and never registered
with SliceJobTracker.
Archive entry point
- POST /slicer-pipelines/{id}/check-eligibility and /run accept
source_archive_id as an alternative to source_library_file_id (XOR,
enforced by Pydantic validator).
- PipelineRun.source_archive_id is a new nullable FK column with the
ALTER TABLE migration in run_migrations (idempotent via _safe_execute,
works on SQLite + Postgres).
- _resolve_source branches: archive path reads source_3mf_path with
fallback to file_path, mirroring routes/archives.py.
- ArchiveCard's context menu picks up a "Run with pipeline" item next to
Slice (only on source archives), gated on useSlicerApi + pipelines:run.
Slice (only on source archives), gated on useSlicerApi + pipelines:run.
- Path-safety: SEC-PATH-OK markers added at both LibraryFile.file_path
and archive.source_3mf_path join sites, citing the upload-time
validators.
Progress toast
- Pipeline orchestration is now the `run` callable of a
slice_dispatch.enqueue call — the same dispatcher SliceModal uses —
instead of a bare asyncio.create_task. The SliceJob lifecycle drives
the existing progress toast end to end with no separate notification
surface for pipeline runs.
- PipelineRun.slice_job_id is set before the 202 returns.
- RunWithPipelineModal calls useSliceJobTracker().trackJob() from
runMutation.onSuccess.
- RunWithPipelineModal source prop is now {kind, id, filename}
mirroring SliceModal.SliceSource; api.checkPipelineEligibility +
api.runPipeline take a discriminated-union source argument.
|
||
|
|
d6bdb7e200 |
feat(slicer): Slicer Pipelines — save & reuse a preset bundle in one click (#1425 PR A)
The SliceModal forces the user to pick four slots every time (printer / process / filament(s) / bed type). For fleet production that's tedious and error-prone. Pipelines let an operator save a named bundle and apply it with one click on the next file. PR A is bundle-and-management only. PR B adds single-target dispatch, PR C adds multi-copy batch with capability-matched fanout. Future-PR columns (target_kind / target_printer_id / target_model_class / fanout_strategy) ship in this migration so PR B+ is code-only, not a schema bump. Backend - New model SlicerPipeline + slicer_pipelines table; soft-delete via is_deleted so PR B+ run history can still resolve metadata. - Pydantic schemas reuse the existing PresetRef shape from schemas/slicer.py. - CRUD routes at /api/v1/slicer-pipelines/ — list (newest first by id DESC), create (201), get-by-id, partial PUT, soft-delete (204). - Three new permissions: PIPELINES_READ / PIPELINES_WRITE / PIPELINES_RUN. Administrators + Operators get all three; Viewers get READ. Backfill in seed_default_groups() so existing installs upgrade cleanly. All three denied to API keys for now. Frontend - Settings → Workflow splits into two horizontal sub-tabs mirroring the Authentication tab pattern: "Queue & Dispatch" (existing Workflow content) and "Pipelines" (new). URL deep-link via ?tab=queue&sub=pipelines. - SlicerPipelinesPanel — list, inline rename, delete, stale-preset warning when a referenced preset no longer resolves. - SliceModal gets "Apply pipeline ▾" + "Save as pipeline". Apply fills all four slot states; the filament list right-pads from current state so a pipeline with fewer entries than the current source's slot count keeps the existing tail. |
||
|
|
4c67d8a4e1 | feat: Unify print dispatch through the scheduler (#1625) | ||
|
|
c236fdc650 |
fix(auth): expose /api/v1/system/appliance through the auth middleware allowlist
The /system/appliance endpoint is fetched by the SPA's i18n bootstrap on mount to seed locale, hostname, timezone, and the chrony NTP-gate state BEFORE any login state exists. The route handler itself has no auth dependency and the test_route_auth_coverage allowlist correctly marks it public, but the global auth_middleware in main.py — which short-circuits every /api/ path not in PUBLIC_API_ROUTES — was never told about it. Result: every browser session on an auth-enabled install logged a 401 on the appliance endpoint before login. Added /api/v1/system/appliance to PUBLIC_API_ROUTES with a comment pointing at the dual-list pattern so this doesn't drift again, and a regression test in TestAuthMiddlewarePublicRoutes that posts /auth/setup to turn auth on, then asserts the endpoint returns 200 with the documented shape (hostname / timezone / locale / time_synced fields all present). |
||
|
|
fd61812d01 |
feat(drying): show active-cycle filament + target temperature on the AMS drying badge
Bambu's per-tick AMS push carries only the dry_time countdown — the
filament name and target temperature the user chose are never echoed on
the wire. The AMS card had no source of truth for them and rendered the
bare "Drying · 11h 35m left". The badge now shows
"Drying · PETG @ 65°C · 11h 35m left", matching the cycle the user
actually started.
BambuMQTTClient caches {ams_id: {filament, temp}} on send_drying_command
(mode=1), clears on mode=0 and on the dry_time falling edge to 0 — the
same per-AMS edge detector that drives the smart-plug-after-drying
callback. PrinterManager.get_drying_targets exposes it, the four
printer_state_to_dict call sites thread it through, AMS schema gains
dry_target_temp + dry_filament, and routes/printers.py builds the same
fields into the manually-constructed AMSUnit response.
When no cached target exists (drying started in a previous backend
lifetime, or initiated outside Bambuddy), the badge falls back to the
first loaded tray's tray_type + RFID-recommended drying_temp — the
heuristic the popover already uses to seed defaults.
i18n: printers.drying.targetSummary = "{{filament}} @ {{temp}}°C" in
all 11 locales. Parity check 5356 leaves per locale.
Note: a user reported the H2D's own physical display still labels the
cycle by the loaded tray's filament (e.g. "PLA" instead of the
Bambuddy-requested "PETG"). The wire payload is correct end-to-end —
journalctl shows filament: "PETG" sent and result: success ACKed — and
the badge in Bambuddy's own UI now reflects what we actually sent,
independent of the firmware's display choice.
|
||
|
|
5c673620f3 |
fix(notifications): false-positive Print Stopped on reprint after MQTT reconnect (#1807)
Reprints triggered a bogus "Print Stopped" push notification while the print kept running, surfaced by the reconciler synthesising a missed PRINT COMPLETE on MQTT reconnect. bambu_mqtt:3647 mints a fresh subtask_id per dispatch. On reprint, the on_print_start expected-archive promotion only wrote subtask_id when the stored value was empty (`not archive.subtask_id`) — so the archive kept the FIRST run's id. On the next MQTT reconnect, reconcile_stale_active_prints (#1542) compared the stale stored id against the printer's live id, found a mismatch, and synthesised a status="aborted" PRINT COMPLETE — which fires the "Print Stopped" notification. Captured cleanly in the reporter's support bundle: [RECONCILE] Printer 1: synthesising missed PRINT COMPLETE for archive 31 — subtask_id changed ('1844213296' → '2103771517') immediately followed by gcode_state: RUNNING on the same wire. Fix: update archive.subtask_id whenever the new effective id differs from the stored one, not only when the stored one is empty. Inequality check preserves the noop-on-stable-push behaviour the original guard provided. Two places in main.py (expected-print and duplicate-printing-archive branches). 3 new unit tests cover the reprint, first-run, and stable-push paths. Reconciler itself unchanged — it was doing the right thing given the data it had. |
||
|
|
8a26e7d753 |
fix(inventory): stop popping the unknown-tag modal for slots with no RFID
The
|
||
|
|
7cb905ad0c |
feat(inventory): toggle to disable auto-add of unknown RFID spools + global confirmation modal (issue #1764)
New setting "Auto-add unknown RFID spools" under Settings -> Filament -> Filament Tracking,
default ON for back-compat. When turned off, the backend stops auto-creating an inventory
record for an unknown RFID tag and instead broadcasts an unknown_tag WS event that pops
a global confirmation modal in the Bambuddy UI showing the printer / AMS-X label / slot /
material / colour. Add or Cancel; no nag on every MQTT push.
Backend
- Module-level _unknown_tag_last_broadcast dict dedupes per (printer, slot, tag). Set is
committed AFTER ws_manager.broadcast() returns so a crashed broadcast doesn't poison
the dedup and permanently silence the slot.
- Empty-slot MQTT push clears that slot's entry, so remove+reinsert reliably re-prompts.
- Successful matches via get_spool_by_tag / find_matching_untagged_spool / create_spool
also clear the entry so a future tag swap re-prompts.
- Tray data (tray_type, tray_color, tray_sub_brands, tray_count) shipped in the WS payload
directly so the modal renders the real material / colour instead of relying on the
React Query cache that lags the WS event by several seconds.
- Two new endpoints back the modal's confirm action:
POST /api/v1/inventory/spools/from-slot (INVENTORY_UPDATE)
POST /api/v1/spoolman/spools/from-slot (FILAMENTS_UPDATE)
Both look up the slot's tray data server-side and create + auto-assign atomically.
- Spoolman /from-slot now raises HTTP 500 when the slot-assignment INSERT fails instead
of returning success while the DB rolled back the binding.
- sync_ams_tray gained an optional auto_add_unknown_rfid kwarg (default True so existing
callers are unaffected); auto-sync and both manual sync routes thread the setting.
Frontend
- useUnknownTagPrompt hook listens for the unknown-tag CustomEvent, reads the tray fields
out of the event detail, and feeds a single-modal queue. No long-lived dismissed set;
the backend dedup handles spam suppression.
- UnknownSpoolModal wraps the existing ConfirmModal with a material + colour-swatch
preview block.
- Mounted in Layout.tsx alongside useSponsorPrompt so SpoolBuddy kiosk / login / setup
routes are excluded.
- getAmsLabel moved to utils/amsHelpers.ts; ConfigureAmsSlotModal.tsx and PrintersPage.tsx
both import the shared version (canonical AMS-A / HT-A / External labels).
- AppSettings TS interface gained spoolman_enabled, auto_add_unknown_rfid, spoolman_url
so the runtime cast in the hook is no longer needed.
- SpoolmanSettings.tsx gets a new toggle row in the Filament Tracking card, visible in
both built-in and Spoolman branches; auto-save + toast already wired.
|
||
|
|
bb42b423af |
feat(file-manager): user-authored tags for cross-cutting filtering (#1268)
Third and final piece of #1268, alongside the recursive-search + README-panel commit that landed earlier in 0.2.5b1. Folders express hierarchy (one home per file); tags are orthogonal labels — "toy", "kid-safe", "petg-only" — and a single file can carry as many as the user wants. Reporter wanted to find "every toy regardless of which folder it lives in"; folders alone can't do that without forcing the file into one bucket. Design decisions locked with maziggy before code: - file-only (folders already express hierarchy) - multi-tag filter = AND - tag filter IGNORES the selected folder (cross-cutting by design) - bulk-tagging from multi-select toolbar in v1 - no auto-tags from 3MF metadata (user-authored only) - label-only chips, no color/icon Backend - LibraryTag (id, name, name_key UNIQUE = LOWER(TRIM(name))) in backend/app/models/library.py. Case-insensitive UNIQUE collapses "Toys"/"toys"/"TOYS " into one row, so the route returns 409 instead of silently fragmenting the catalog. - LibraryFileTag(file_id, tag_id) association, composite PK, ON DELETE CASCADE both directions. Deleting a tag drops every chip; files survive. Deleting a file drops its tag links; the catalog row survives. - Both tables auto-create via Base.metadata.create_all — no explicit run_migrations step needed for new tables. - New router at backend/app/api/routes/library_tags.py with: GET /library/tags (list + per-tag file_count) POST /library/tags (create, 409 on case-insensitive dup) PATCH /library/tags/{id} (rename, 409 on collision, self-rename OK) DELETE /library/tags/{id} (cascade) POST /library/tags/bulk-assign (add | remove | replace) - Bulk-assign add is idempotent; replace with empty tag_ids clears the file's tag set. Per-file ownership enforced — *_OWN callers can only modify their own files; unknown file_ids quietly skipped (matches library_trash bulk shape). - list_files gains tag_ids: list[int] query param. AND semantics via JOIN + GROUP BY + HAVING COUNT(DISTINCT) — portable across SQLite and Postgres. When tag_ids is non-empty, folder_id / project_id / include_root / recursive are all bypassed so the result is cross-cutting. - FileListResponse gains tags: list[{id, name}] via selectinload(LibraryFile.tags) — N+1-free chip render. - Permissions reuse existing constants: LIBRARY_UPDATE_ALL for catalog mutations (global catalog, ownership-aware update isn't meaningful), LIBRARY_UPDATE_ALL/OWN pair for bulk-assign, LIBRARY_READ_ALL/OWN for list — file_count projection narrows for *_OWN callers so chip counts match what they actually see. Frontend - LibraryTagsModal (catalog CRUD) opens from the toolbar's new Tags button. max-w-4xl so multi-language subtitles don't wrap. Delete-with-warning when file_count > 0 ("removes the chip from all of them; files themselves are untouched"). - BulkTagsPickerModal opens from the multi-select toolbar (new Tag button between Move and Delete). Add/Remove radio, checkbox list, inline "create new tag" disabled on dup. Apply disabled until at least one tag is selected. The replace action is exposed in the API but deliberately NOT in this UI — arbitrary multi-file replace is destructive and confusing. - FileManagerPage integration: * selectedTagIds state, sorted into the useQuery key so the cache hits are stable regardless of toggle order * filter rail above the file list lists EVERY catalog tag as a togglable chip — inactive outlined, active filled green with an X. Clear all when 1+ active. Bar hidden entirely when catalog is empty. * useEffect prunes selectedTagIds when a tag is deleted from the catalog so the filter never strands on a phantom id * dedicated Tags column in list view at minmax(0,200px) between Prints and Actions * grid view chips render below the metadata block * chip clicks stop propagation so they don't toggle file selection - libraryTagsQueryKey extracted to frontend/src/utils/ libraryTagsQuery.ts so component files export only components (Vite react-refresh rule). - LibraryFileListItem.tags is OPTIONAL even though the backend always emits an empty array — legacy msw mocks in pre-existing tests construct partial file shapes without the field. Without the ? the FileCard renderer crashed on .length and broke 49 unrelated tests across FileManagerPage + FileManagerExternalFolder. Read sites use file.tags ?? []. |
||
|
|
12b21ce0df |
fix(notifications): sync finish-photo producer→consumer to land the photo on FINISH-state fallback (#1790)
On the FINISH-state fallback path bambu_mqtt.py:3258 dispatches on_finish_photo_moment and on_print_complete back-to-back, so the moment-producer's RTSP grab and the print-complete consumer's cache read race — consumer wins the empty pop, then its own RTSP fallback times out against the producer's in-flight grab (Bambu printers allow one RTSP client). 394 KB frame captured, notification went text-only. Add a per-printer asyncio.Event in _stage22_finish_in_flight: producer registers before first await, sets it in finally on every exit; consumer awaits with a 20s timeout (15s producer grab + headroom) before the cache pop. Closes the race AND the concurrent-RTSP timeout in one change. Timelapse path skips the event, so its branch is unchanged. |
||
|
|
68b9d741d9 |
feat(humidity): per-filament humidity threshold for auto-drying + alarms (#1605)
Reporter @thenewguy runs an engineering farm with one AMS per material
(PLA, ASA, Nylon, PVB, HIPS) — Bambuddy's single global ams_humidity_fair
threshold (default 60%) was driving both the queue / ambient auto-drying
trigger AND the hourly humidity alarm uniformly, which is wrong for
multi-material setups where Nylon wants <10% and PLA is fine at 60%.
Drying RUN parameters were already per-filament via drying_presets;
this commit adds the missing per-filament TRIGGER.
New setting ams_humidity_thresholds — JSON map of filament-type to
threshold percent with a "default" key for unknown / unmapped types.
Empty / unset → both consumers fall back to ams_humidity_fair so the
upgrade is silent.
Resolver lives in PrintScheduler.resolve_humidity_threshold(trays,
thresholds, fallback) — picks the lowest (most-restrictive) threshold
across all loaded tray types, matching the conservative-params strategy
_get_conservative_drying_params already uses for temp / hours. Empty
tray slots contribute no constraint; all-empty AMS falls through to the
"default" key. Filament names normalized to uppercase base (so
"PLA Basic" / "pla basic" both map to PLA).
Two consumer sites rewired through the same resolver so the scheduler
and the alarm path can never disagree about whether an AMS is "too
humid":
- print_scheduler.py::_check_auto_drying — per-AMS humidity comparison
for start / stop / skip decisions.
- main.py AMS sensor / alarm worker — hourly humidity alarm notifier.
UI: new table in Settings → Workflow → Auto-Drying, below the existing
Drying Presets table. Default row + 8 default filament types
(PLA / PETG / TPU / ABS / ASA / PA / PC / PVA) pre-filled from the
current ams_humidity_fair value so the editor starts sensibly.
Input pattern: draft-on-edit / commit-on-blur (transient humidityDrafts
state per row). onChange only updates the draft; onBlur (and Enter)
parses + clamps to [5, 95] + commits. Empty value on blur clears the
override and falls back to default. Caught mid-PR via a typing test:
the naive per-keystroke clamp snapped "3" → 5 before the user could
type the second digit of "30".
Setting is in the public _UI_PREFERENCE_FIELDS allowlist (same rationale
as drying_presets and ams_humidity_fair — non-sensitive integer map,
no SETTINGS_READ permission required for badge-color rendering).
|
||
|
|
a5fe5cb3d4 |
feat(sponsor-prompt): in-app toast at earned milestones
ghcr.io pull baseline (~10k/day rising → ~8-12k active installs) puts
sponsor conversion at 0.08% — roughly an order of magnitude under
industry-benchmark for OSS with visible CTA. The Settings banner from
|
||
|
|
090c180ebf |
feat(heater-history): track nozzle / bed / chamber readings + per-tile chart-icon overlay opens history modal
New PrinterSensorHistory table + 60s recorder + GET/DELETE /printer-sensor-history route gated behind a new PRINTER_SENSOR_HISTORY_READ scope (separate from AMS). UI adds a 10x10 LineChart icon on each heater tile - click body opens the existing target-temp popover unchanged, click icon opens a HeaterHistoryModal mirroring the AMSHistoryModal shape (kind toggle + 6h/24h/48h/7d range + current/avg/min/max + recharts line for value + dashed target). Read-only X1C/P2S chamber tile finally gets an interaction. Retention configurable via printer_sensor_history_retention_days (default 30, sibling of ams_history_retention_days). 8 new i18n keys translated in all 11 locales, parity green. 4 backend + 6 frontend tests added; full pytest -n 30 6226/6226, vitest 2176/2176, ruff/eslint/build all clean. |
||
|
|
b1cb26f6ee |
feat(ams-backup): add status badge + toggle, fix prefer-lowest (#1766)
Two tightly-coupled deliverables in one drop -- a new AMS Filament Backup status/control surface, and the #1766 fix that depends on it. Added -- AMS Filament Backup status + control - Parse bit 18 of top-level print.cfg into PrinterState.ams_filament_backup on every push_status. Verified against OrcaSlicer source (DeviceManager.cpp:4961) and a live H2D ON/OFF capture. Tri-state (None = A1 family / pre-cfg push) preserves today's behaviour. - Hold-timer guard (3 s) prevents stale frames from flickering the badge back to the printer's old cfg after a user-initiated toggle. - POST /printers/{id}/ams-backup toggle, set_ams_filament_backup() client method calling _set_print_option("auto_switch_filament", enabled). - GET /printers/{id}/inventory-remain endpoint exposes the same map the dispatcher uses (internal and Spoolman modes both work uniformly). - Small icon badge in the printer card's "Filaments" section header (placement reads as printer-wide because the cfg bit is printer-wide, not per-AMS). Click to toggle, success toast. - 5 i18n keys x 11 locales for the badge UI. Fixed -- #1766: prefer_lowest didn't pick lowest, ignored backup state - Backend gate in _compute_ams_mapping_for_printer: coerce prefer_lowest to False when status.ams_filament_backup is False; log the skip. - New effectivePreferLowest(setting, backup) helper applied at every frontend sort entry point: single-printer PrintModal, multi-printer hook per-printer, PrinterSelector InlineMappingEditor, FilamentMapping standalone editor (the last had NO preferLowest awareness at all before this change). - New preferLowestSortKey(f, inventoryByTrayId) mirrors backend's two-tier key exactly, including the banding tie-break (regular AMS < AMS-HT < external) so the client-side pre-compute matches the dispatch-time pick. An earlier draft used a flat `amsId * 4 + trayId` priority which gave external slots (ams_id = -1) a NEGATIVE priority -- caught in code review before commit. - Settings -> Filament -> "Prefer lowest remaining filament" gets an explanatory note about the printer-side AMS Backup dependency, with i18n key in all 11 locales. |
||
|
|
2f6007a148 |
fix(notifications): scope completion notification to printed plate on multi-plate 3MFs (#1785)
The 3MF parser sums prediction + weight across every plate (#1593) so the archive card can headline the whole project — correct for the card, wrong for the completion notification of a single plate. The queue UI already re-reads the 3MF per-plate at print_queue.py:272-285; mirror that for the notification path so Discord / Pushover / email show the plate's actual duration and grams instead of the project sum. Helper fails open on every error path so a missing or corrupt 3MF can't block the notification. |
||
|
|
ead6c37147 |
fix(notifications): wire on_printer_offline dispatch on disconnect edge (#1752)
The provider toggle, schema, template, and NotificationService.on_printer_offline all shipped, but no caller invoked the dispatcher — the offline event was an orphan toggle. Edge detection in on_printer_status_change now schedules a debounced (60s) background task on the connected→disconnected transition; reconnect before the window elapses cancels it. Covers both upstream paths (smart-plug power-off via mark_printer_offline, and MQTT staleness via check_staleness), both of which already route through the status callback. The "back online" channel is the existing print-failure notification on firmware FAILED report — no symmetric on_printer_online needed. |
||
|
|
be7e85344c |
fix(finish-photo): drive capture from stg_cur=22, drop dispatch force-on (#1721)
capture_finish_photo (default-on) was forcing the timelapse MQTT field to true on every print, even when the user explicitly unchecked Timelapse in the slicer send dialog. On profiles with Timelapse Type = Smooth, that flipped the printer's timelapse_record_flag and un-gated the per-layer M622 J1 wipe blocks the slicer had baked in — toolhead parked off the part every layer, on prints the user opted out of recording. Root cause: #1397 implemented the finish-photo feature as a side channel of "force the printer into timelapse-recording mode at dispatch" so the last-frame extractor had a video to pull from. That conflated recording a timelapse with snapping a finish photo, and the per-layer side effects were decided at slice time by the user's timelapse_type, which Bambuddy has no visibility into post-slice. Fix: replace the force-on with a clean MQTT-state-driven trigger. bambu_mqtt.py fires a new on_finish_photo_moment callback when stg_cur transitions INTO 22 ("Filament unloading") while _was_running AND end-of-print gate matches (progress >= 99 OR layer_num >= total_layers OR remaining_time <= 0). The gate disambiguates from mid-print color swaps (which also transit stage 22 but at progress < 99). FINISH-state fallback in the same handler fires the callback at the existing transition if stage 22 never arrived (cancel, external-spool-only, HMS halt, firmware variants). main.py registers on_finish_photo_moment as a top-level handler. It pre-captures one camera frame at the trigger edge (external cam → buffered RTSP → fresh RTSP via capture_camera_frame_bytes) and caches the JPEG bytes in _stage22_finish_frames[printer_id]. _background_finish_photo consumes the cached bytes before its existing live-grab chain, so the saved photo has the better framing (toolhead parked, before bed drop) without restructuring the archive-resolution / fallback / notification wiring. When a timelapse IS actively recording (user explicitly opted in), pre-capture is skipped — _capture_finish_photo_from_timelapse still extracts the last frame, which is still the best framing and now has no force-on side effects because the user wanted the video. Removed: resolve_effective_timelapse, _resolve_effective_timelapse wrapper, both background_dispatch call sites, the print_scheduler call site, the archive.bambuddy_forced_timelapse write, _cleanup_forced_timelapse (~75 lines including the FTP-DELE walk across /timelapse, /timelapse/video, /record, /recording) and its call site. All paths now read bool(item.timelapse) / bool(job.options.get("timelapse", False)) directly. The archive.bambuddy_forced_timelapse DB column stays defined (default False) for back-compat with existing rows — no consumer reads it anymore. |
||
|
|
7190fc2d13 |
fix(logs): demote benign "not connected" + "may linger" warnings
Two warnings polluting every A1 support bundle on healthy prints, both
unrelated to the timelapse-default behaviour the issue actually reports.
1. mqtt_bridge.py's post-bind nudge calls request_status_update on the
real printer's MQTT client to populate the bridge cache without
waiting for the next periodic pushall. The bind frequently races the
TLS handshake, especially on A1 firmware. Skip the nudge when
state.connected is False — the periodic pushall fills the cache
anyway. The WARNING in bambu_mqtt.py stays for the genuinely-
actionable callers (refresh-status API, bug reporter).
2. Post-finish SD-card cleanup (and the symmetric forced-timelapse dir
walk) used delete_file_async's bool return to drive a WARNING when
all candidates failed. A1 firmware self-cleans the SD card before
our cleanup runs — every candidate FTP-DELE returns 550, we burn
the retry budget, then WARN on a successful print. Introduce
DeleteResult.{DELETED,NOT_FOUND,FAILED} so the helpers only WARN
on real network/auth/transient failures. NOT_FOUND advances to the
next candidate without consuming the 2s backoff. User-facing delete
endpoint returns 404 on NOT_FOUND.
|
||
|
|
bf781e0566 |
fix(notifications): reprint-from-archive sent original print's finish photo (#1707)
Reprints reuse the source archive row via the expected-print promotion branch, but that branch never reset archive.timelapse_path. The stale path made _scan_for_timelapse_with_retries early-return (so the new run's MP4 was never downloaded), and _capture_finish_photo_from_timelapse then extracted the *original* run's last frame and shipped it to Telegram as the new run's finish photo. Surface was specific to the timelapse-prefer path (data.timelapse_was_active=true and no external camera) — fallback live-camera paths were unaffected, which is why this took a P2S reporter to surface. Clear archive.timelapse_path at promotion and unlink the stale on-disk MP4 (best-effort; missing files are logged and skipped). The orphan unlink also kills a long-standing file-leak: every reprint used to leave its predecessor's timelapse on disk forever. archive.photos is deliberately left alone — accumulating one finish photo per run is correct. |
||
|
|
0793022818 |
fix(ams): keep slot card preset name in sync after spool swaps
The slot card on PrintersPage shows slot_preset_mappings.preset_name first in its display fallback chain. Three write paths swap which spool occupies a given slot: - internal manual assign (inventory.apply_spool_to_slot_via_mqtt) - internal RFID auto-assign (spool_tag_matcher.auto_assign_spool) - Spoolman RFID sync (main.auto_sync_spoolman_ams_trays) Only the first one was reconciling the row. After an RFID-driven spool change, the card kept surfacing the previous spool's preset name until the user opened Configure Slot manually. Reporter saw H2D-1 / AMS-B3 displaying "Bambu PLA Silk+" for a freshly-inserted Bambu PLA-CF spool. The matching row in slot_preset_mappings was last written in March when a PLA Silk+ spool had been in that slot - confirmed live in the database. New backend/app/services/slot_preset_writer.py exposes a primitive upsert_slot_preset plus two derivation wrappers: one for the internal Spool ORM object, one for the Spoolman API dict shape. All three call sites now go through the helper, so the row stays in lockstep with the assigned spool regardless of inventory mode. Bug shape exists in both inventory modes and the patch fixes both per feedback_inventory_modes_parity. The Spoolman path was latent for users who'd never manually picked a slot preset; the same "stale row overrides correct catalog name" symptom appeared for those who had. Existing stale rows self-heal on the next RFID-driven swap. |
||
|
|
72044e3a53 |
fix(usage): scope 3MF filament tracking to dispatched plate (#1697)
When a print targets a single plate from a multi-plate 3MF, both the
internal Filament Inventory tracker and the Spoolman-mode tracker parsed
the 3MF without a plate filter and summed every plate's filament — so a
single lid print debited the spool the entire file's grey + black totals.
The 3MF parser already supports plate_id (queue pre-flight uses it at
print_queue.py:254/:286). Plumbed it through both dispatch paths:
Queue path:
- PrintSession gains a plate_id field; on_print_start queries the
printer's currently-printing queue row and records queue_item.plate_id
onto the session.
- _track_from_3mf accepts plate_id and passes it to the extractor.
- store_print_data moves its existing queue-item lookup above the
extract and uses queue_item.plate_id as the plate filter.
Direct-Print path (reprintArchive / printLibraryFile — never goes
through the queue):
- _print_plate_ids dict added in main.py, parallel to _print_ams_mappings.
- register_expected_print accepts plate_id and stores it; the 2 sites in
background_dispatch.py and the 1 site in print_scheduler.py now pass
it (resolve was already happening, just needed reordering before the
register call so the value is available).
- Expected-print promotion in main.py injects _print_plate_ids[archive_id]
into the session, guarded so a queue capture wins over the dict.
- _get_start_plate_id helper feeds plate_id into all 3
_store_spoolman_print_data call sites; spoolman_tracking.store_print_data
takes the caller value first, falls back to queue_item.plate_id.
PrintArchive.filament_used_grams stays file-level summed by design
(#1593's contract — the archive describes the file, not the run); only
the per-run usage attribution becomes plate-aware. Single-plate direct
prints resolve to plate_id=1 → plate 1 = whole file, identical to the
prior no-filter behaviour.
|
||
|
|
d0d6a659e9 |
fix(reconcile): don't synthesize aborted PRINT COMPLETE on the bare-connect edge before push_status arrives (#1679)
on_printer_status_change fires from MQTT _on_connect BEFORE the first push_status round-trips, when PrinterState is still on construction defaults (state="unknown", subtask_name=""). The connected-edge reconcile was treating that degenerate state as evidence and synthesising aborted PRINT COMPLETE for every in-flight archive on every Bambuddy restart. The reactive PRINT COMPLETE then created a duplicate archive (lookup misses on cleared _active_prints), so filament got deducted twice. Two-layer guard: gate the reconcile spawn on a real state.state, and make _is_active_archive_stale return not-stale on unknown/empty input as defensive fallback. #1542 behaviour preserved — real stale archives still get caught the moment a real push_status arrives. |
||
|
|
f243e4e598 |
fix(asyncio): track strong refs on orphan create_task sites
asyncio holds only a weak reference to tasks returned by ``create_task``. Fire-and-forget callers that discard the return value let the event loop GC the task before it finishes, logging ``Task was destroyed but it is pending!`` with no traceback. The #1648 support-bundle review surfaced 94 such warnings in 8 days of v0.2.4.5 -- the silently-vanished exceptions reach support bundles as opaque GC notices instead of actionable errors. New backend/app/core/tasks.py::spawn_background_task(coro, *, name=None) is the one place in the codebase that calls asyncio.create_task. It stores the task in a module-level set, attaches a done-callback that auto-removes on completion AND surfaces any uncaught exception via the logger with the originating traceback, and accepts name= so a leak source is traceable through /tracebacks and the log line. Cancelled tasks don't log (a shutting-down service is not an error). Migrated the 16 truly-orphan create_task call sites to the helper: main.py (8): reconcile-stale, cooldown-poweroff, energy calc, smart-plug, maintenance-check, photo-then-notify, layer-timelapse, scan-timelapse, print-scheduler, notify-no-archive (the last one was hand-rolling the same pattern with task + no-op done_callback) printers.py:3123 apply-pa-after-refresh print_queue.py:1034 queue cooldown-poweroff firmware_update.py:261 firmware upload archive.py:1514 timelapse mp4 convert print_scheduler.py:2199 watchdog print-start library.py:1614 STL backfill smart_plugs.py:259 tasmota scan discovery.py:159 subnet scan smart_plug_manager.py x3 plug auto-off-pending background_dispatch.py x2 (lambda-wrapped inside loop.call_soon_threadsafe) upload progress Sites that already kept strong refs are unchanged: self._tasks.append(asyncio.create_task(...)) -- VP manager, tcp_proxy, mqtt_server self._x_task = asyncio.create_task(...) on service instances -- mqtt_bridge, obico_detection, github_backup, archive_purge, local_backup, library_trash, discovery service Locally assigned + awaited/gathered -- tcp_proxy bidirectional pumps, camera_fanout, slice_dispatch, slicer_api progress_task, manager._finish_release_task, main.py module-level cleanup loops |
||
|
|
12d17bfbe7 |
fix(photo): source finish photo from forced timelapse + cleanup (#1397)
Bambu's end-gcode lowers the bed at gcode_state=FINISH. Bambuddy's live-camera grab captured the bed already dropped, ruining the photo framing. Source the photo from a brief Bambu timelapse instead — firmware stops timelapse recording AFTER toolhead parks but BEFORE bed-drop runs, so the last frame frames the finished print correctly. When capture_finish_photo is on AND the user did not opt in to timelapse for this print, force timelapse=True at dispatch + mark the new PrintArchive.bambuddy_forced_timelapse column. After extraction (success or failure), cleanup deletes the locally-attached file, clears archive.timelapse_path, and walks the four scanner directories (/timelapse, /timelapse/video, /record, /recording) trying FTP DELE against the original filename. User-opted-in timelapses pass through unchanged. Resolver lives at services/background_dispatch.py::resolve_effective_timelapse (module-level so the print queue can reuse it). Both dispatch paths wired: background_dispatch.py (Print Now / Reprint) AND print_scheduler.py:_start_print (the queue). Field testing caught the scheduler gap on the first round — AST regression test now asserts start_print(timelapse=...) references effective_timelapse, not the raw item.timelapse, so a future refactor can't silently drop it. Extractor: ffmpeg -i input.mp4 -update 1 -q:v 2 out.jpg. Decoded frames overwrite the same output file, so the file left on disk is the literal last frame regardless of duration. Bambu records one frame per layer-change, so a 16-layer cube produces a 0.6 s timelapse — the original -sseof -1.0 approach seeked before the start of the file and returned frame 0 (empty bed). Decoding every frame is fine; Bambu timelapses are short by construction even on hours-long prints. Migration adds bambuddy_forced_timelapse branched on is_sqlite() (DEFAULT 0 / DEFAULT FALSE — PG rejects DEFAULT 0 for BOOLEAN). Verified live on postgres:16-alpine. Photo-task wait_for budget extends 45s -> 75s when timelapse_was_active so the notification carries the bed-up photo instead of falling back to the live-cam grab on slow links. Scope limit, documented in the camera wiki: prints started directly on the printer touchscreen / Bambu Handy / Bambu Studio Send bypass both dispatch paths, so the override doesn't fire there. Future option: mid-print M981 S1 P20000 MQTT toggle in on_print_start. Setting description rewritten in all 11 locales to drop the "only works when timelapse enabled" caveat (Bambuddy now forces it) and explain the kept-or-deleted behaviour. |
||
|
|
18d534c945 |
feat(orca-cloud): integrate Orca Cloud profile sync across UI, slicer and SpoolBuddy
Reads, lists, and slices with profiles from a user's Orca Cloud account (OrcaSlicer 2.4.0-alpha's Supabase-backed sync) alongside the existing Bambu Cloud integration. Four sign-in providers (Google / Apple / GitHub / email+password); password defaults. Paste-flow PKCE because Orca's Supabase project only allowlists localhost redirect_to — open feature request at OrcaSlicer/OrcaSlicer#14028. Surfaces: - Profiles tab: new "Orca Cloud" tab next to "Bambu Cloud" with the same rich layout (search + 5 filter dropdowns + 3-column grouped grid + read-only detail modal) - SliceModal: 4-tier preset picker (orca_cloud > local > bambu cloud > standard); separate status banner per cloud; metadata-aware pre-pick scores Orca filaments above local (Orca's sync_pull returns full content inline so filament_type / filament_colour come for free, no per-setting fetch rate-limit dance) - ConfigureAmsSlotModal: orca_cloud as a new preset source (prefixed orca_<UUID> to match local_/builtin_); generic Bambu filament-ID derivation from parsed material (printer firmware can't grok Orca UUIDs); slot mapping persists preset_source='orca_cloud' - SpoolForm / SpoolBuddyWriteTagPage: Orca filaments merge into the cloud preset list via Promise.allSettled (OrcaProfileMeta is structurally identical to SlicerSetting) Backend: - services/orca_cloud.py: OrcaCloudService with PKCE / token exchange / single-use refresh rotation / get_user_info / list_profiles via the bare /sync/pull bootstrap path - routes/orca_cloud.py: 7 endpoints (auth/start, auth/finish, auth/password, status, logout, profiles, profiles/{id}); router-level _cloud_api_key_gate + per-route cloud_caller() so API-keyed callers (SpoolBuddy kiosk) properly resolve their owner User; just-in-time refresh with atomic persist-before-API-call - routes/slicer_presets.py: _fetch_orca_cloud_presets mirrors the Bambu Cloud fetcher (status vocabulary, 5min cache, permission shortcut); _dedupe_by_name extended to 4 tiers; UnifiedPresetsResponse gains orca_cloud + orca_cloud_status - services/preset_resolver.py: PresetRef.source extended with "orca_cloud"; _resolve_orca_cloud walks list + filters - 8 columns on users table for tokens (5 persistent) + transient PKCE handshake state with 10-min TTL (3); dialect-branched DATETIME / TIMESTAMP; auth-disabled mode falls back to Settings table - orca_cloud:auth permission folded into can_access_cloud API-key scope (same trust dimension) |
||
|
|
51730a7bf1 |
feat(ams): gate humidity/temperature alarms on AMS-has-filament (#1619)
The hourly AMS sensor recorder dispatched humidity and temperature alarms for every unit above threshold without checking whether the unit was actually loaded. Empty AMS units still report ambient readings, so users with one loaded + one empty AMS got useful alarms for the loaded one and hourly noise for the empty one. Disabling the whole alarm category killed both — not a real choice. New _ams_has_filament helper inspects tray_exist_bits (hex bitmap, "0" = empty) with fallback to the tray array's tray_type strings for shapes where the bitmap is missing. The recorder gates the alarm dispatch on this check per-AMS-unit, so a multi-AMS printer with one loaded + one empty still alarms on the loaded one. Sensor history still records regardless of the gate so the System page humidity charts stay continuous — only the outbound notification is suppressed. 9 unit tests cover the bitmap-zero case, bitmap-missing fallback, garbage/blank/int bitmap edges, and defensive malformed tray. |
||
|
|
e10462678f |
fix(security): GHSA-6mf4-q26m-47pv — fail-closed on auth-probe DB errors (CVSS 9.8)
is_auth_enabled() and auth_middleware both caught every exception during
the auth-state probe and returned the "allow" answer instead of denying
the request. Reporter's PoC floods /api/v1/auth/login to exhaust file
descriptors, forcing the next SQLite connect to raise, then hits a
protected endpoint during the fail-open window with no token — granting
unauthenticated access to admin-account creation, API-key creation, DB
backup download, and printer control. CWE-636 / CWE-755. Affects >= 0.1.6.
Fix:
- is_auth_enabled (backend/app/core/auth.py): only returns False for the
legitimate "settings row absent" case; any actual exception propagates
so the caller can deny the request.
- auth_middleware (backend/app/main.py): returns 503 on any probe failure
instead of await call_next(request).
4 new regression tests in test_auth_fail_closed.py pin the contract
(propagates DB exceptions, returns False for no-row, True for "true",
False for "false"). 1 existing security test renamed and updated to
accept either 500 or 503 (both fail-closed) and to verify the
SQLAlchemy detail does not leak in the body.
Codebase grep confirmed no other auth-decision predicate has the same
fail-open shape: _validate_api_key returns None on catch (→ 401 fail-
closed downstream), is_advanced_auth_enabled propagates correctly,
permissions.py has no catch-alls.
Reported by @wondercrash via private advisory.
|
||
|
|
ed232718f5 |
fix(prints): connected-edge reconciliation closes the missed-PRINT-COMPLETE loop behind smart-plug ghost prints (#1542 follow-up)
Reporter ran a fresh trace after the doubled-extension fix landed and
found a distinct second cause behind his ghost prints, hitting 4-of-4
of his A1s. Timeline:
22:50 PRINT START
...print runs all night...
23:13 / 00:47 / 09:35 MQTT disconnects (A1 keepalives are unstable)
print finishes during one of those disconnect windows → PRINT COMPLETE
is never observed
smart plug detects idle → cuts power
power resumes for the next scheduled print → firmware auto-replays the
leftover .3mf from the SD card
09:46 Bambuddy reconnects to a fresh PRINT START for the ghost
The existing IDLE-after-RUNNING completion check at
backend/app/services/bambu_mqtt.py:3022 was meant to catch the simple
disconnect-then-finish case via `_previous_gcode_state` preserved across
reconnects, but with multiple disconnect/reconnect cycles + a smart-plug
power-off that Bambuddy can't distinguish from any other transient drop,
the IDLE window that branch needs simply never reaches it. The SD .3mf
lingers, the firmware ghost-replays every power cycle, and the loop
repeats.
Fix: new connected-edge reconciliation pass.
* `_is_active_archive_stale(archive, state)` — pure decision function
with three triggers:
(1) printer state is terminal (IDLE / FINISH / FAILED)
(2) printer running with a different `subtask_id` than the archive —
Bambu firmware mints a fresh subtask_id for each print including
the ghost replay, so a mismatch is unambiguous
(3) printer running but `subtask_name` is empty — printer doesn't
know what it's running, archive reference is broken
Conservative on PAUSE / PREPARE / SLICING and on RUNNING with matching
subtask. False-positive cost = one misreported "aborted" status that
the next real PRINT COMPLETE would have overwritten anyway. False-
negative cost = the ghost-print loop.
* `reconcile_stale_active_prints(printer_id)` — queries archives in
`status="printing"` for the printer, runs the decision function, and
synthesises `on_print_complete(status="aborted", _reconciled=True)`
for each stale match. Reuses the existing PRINT COMPLETE chain (SD
cleanup, status update, usage tracker, notifications) — no reimplem-
entation. Per-archive try/except so one failure doesn't block the
rest. Returns 0 when status is None / disconnected — the connected
edge is the only legitimate trigger.
* `on_printer_status_change` now runs a connected-edge check at the
start. New `_printer_reconciled_since_connect: dict[int, bool]`
tracker flips False → True on the first connected status update for
this connection and back to False on disconnect, so reconciliation
fires exactly once per (re)connection. The flag is set BEFORE the
task is spawned so concurrent status updates within the same
connection don't re-trigger it (no await between check and set,
asyncio guarantees atomicity). The reconciliation runs as
`asyncio.create_task` so the hot WebSocket dedup / broadcast path
isn't blocked.
* Single handler covers both startup and reconnect — when the first
MQTT connection completes after startup the printer pushes status,
the connected edge fires, reconciliation runs. No separate startup
hook needed.
Idempotency: when the existing #3022 branch DOES fire on a clean
disconnect-then-IDLE-on-reconnect, it lands `archive.status` to terminal
synchronously. The async-scheduled reconciliation then queries
`WHERE status="printing"` and finds 0 rows → no-op. The narrow race
where reconcile's query lands between a real on_print_complete's
archive update and its archive lookup produces at most a duplicate
notification (no double SD-cleanup since FTP delete on a missing file
is a no-op 550).
Ghost-print collateral worth being explicit about: if the ghost is
already running when reconciliation fires, the synthesised SD-cleanup
hits 550-file-locked (same root cause as the #1542 first case). The
cleanup retries 3× then logs "lingering". The ghost runs to completion,
its own end-of-print cleanup deletes the file, the next power cycle
has nothing to replay, the loop breaks. A perfect cancel would require
a `print_stop` MQTT command to the printer mid-ghost — invasive,
explicitly out of scope.
|
||
|
|
7a7dfed81a |
fix(archives): resolve raw_data wrapper in fallback-archive filament extraction (#1533 follow-up)
Reporter updated to 0.2.5b1 expecting the #1533 fix to populate filament fields on his P2S virtual-printer prints when the .3mf is locked. His support bundle showed Bambuddy still creating fallback archives with NULL filament fields even though the print-start log line proved AMS-0-T0 had PETG loaded at the moment the helper should have read it (`AMS 0: T0(type=PETG, color=FFFFFFFF, ...)`). Cause: the #1533 helper `_extract_filament_data_from_mqtt(data)` in backend/app/main.py only looked at `data["ams"]`, but the dict that on_print_start actually receives at runtime is the wrapper shape `{"filename", "subtask_name", "remaining_time", "raw_data": <mqtt>, "ams_mapping"}` that backend/app/services/bambu_mqtt.py:2971-2980 constructs. So `data["ams"]` was undefined on every real call and the helper silently returned `{}`, leaving the fallback archive's filament_type / filament_color NULL — the exact regression the original fix was meant to close. The 15 unit tests that shipped with #1533 all passed the bare inner shape directly and never exercised the callback wiring, so the regression slipped through a green build. Surrounding code in the same fallback path (main.py:2064, 2674) already reads `data.get("raw_data")` — the original hunk was the outlier that forgot the wrapper. Fix: helper now resolves `data["raw_data"]["ams"]` first (the callback shape) and only falls back to `data["ams"]` when the wrapper isn't present, preserving the inner-shape callers from the existing tests. Defensive against a non-dict `raw_data` (e.g. partial MQTT decode failure) falling through to the inner lookup instead of crashing. Tests: 5 new in `TestOnPrintStartCallbackShape` in test_fallback_archive_mqtt_filament.py — wrapper payload with ams_mapping resolves to the inner AMS state; wrapper without ams_mapping lists all loaded slots; the existing inner-shape callers still work after the additive wrapper lookup; missing raw_data returns `{}` instead of raising; junk raw_data (string) doesn't shadow a present inner `ams`. Existing 15 inner-shape tests untouched and green. Full 5378-test backend suite green; backend ruff clean. What this does NOT fix: per-filament gram usage still needs the actual .3mf — the printer locks it during print (P-line firmware behaviour, not a Bambuddy bug), and the existing 19 FTP candidate paths + directory probes are expected to 550 in that window. Per-print filament type and colour are the data point the reporter explicitly called out as load-bearing for AMS-expansion planning at his maker space, so this is what moves the needle. |
||
|
|
6d316c5593 |
fix(dispatch): align SD cleanup with upload path so doubled-extension library rows don't leave ghost prints (#1542)
A library row with archive.filename "Cube (1).gcode.3mf.gcode.3mf" uploaded
to /Cube_(1).gcode.3mf.3mf (single-iteration strip + append). Post-print
cleanup looked at /Cube_(1).3mf and /Cube_(1).gcode (subtask_name + ext),
missed the on-card file, and A1 firmware re-ran it on next power-on.
- New derive_remote_filename() helper in backend/app/utils/filename.py:
iterative strip of .gcode.3mf/.3mf suffixes, append single .3mf,
space->underscore. isinstance() guard raises TypeError on non-str
input rather than entering the strip loop with a duck-typed
object that returns truthy sentinels from endswith.
- Three previously-duplicated upload sites (background_dispatch reprint +
library, print_scheduler queue) now share the helper.
- SD cleanup fetches archive.filename and tries the derived path first,
with the legacy subtask_name + ext paths kept as fallbacks.
- 10 new unit tests pin the reproducer + edge cases (doubled .gcode.3mf,
doubled .3mf, raw .gcode preserved, idempotence, Unicode, plus the
type guard against MagicMock / None / int inputs).
|
||
|
|
c42e923e4c |
fix(archives): MQTT-derived filament type/color on fallback archives (#1533)
When the source .3mf can't be downloaded at print start (P1S/A1/P2S
firmwares lock the file mid-print), main.py creates a fallback
PrintArchive with file_path="" and every filament field NULL — even
though the MQTT payload already has the AMS state and the slicer's
slot-per-print-filament mapping (data["ams"]["ams"] and
data["ams_mapping"]).
New _extract_filament_data_from_mqtt(data, ams_mapping) builds a
{global_tray_id: (type, color)} map from the AMS units, then narrows
to slots referenced by ams_mapping (slicer order preserved, -1 VT-tray
sentinels skipped) or falls back to every loaded slot when no mapping
is present. Returns comma-separated filament_type and filament_color
matching the 3MF-extraction shape, so the inventory page, Quick Stats
rollup, and len(filament_type.split(",")) per-print count behave
identically for fallback rows.
The constructor at the fallback site now passes the resulting values
into the PrintArchive row.
This does NOT recover per-filament gram usage — that needs the .3mf's
slice_info.config or a deeper layer-delta integration via usage_tracker.
The reporter (maker-space lead evaluating Bambuddy partly for AMS
expansion planning) asked specifically for "the number of filaments
used", which is what this gives them.
15 unit tests cover empty/malformed payloads, the no-mapping path,
mapping filtering and reordering, VT-tray sentinels, dual-AMS global
ids, column-limit truncation, and defensive garbage handling.
|