mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-05 13:41:36 +02:00
8fd1f884dcc1220a818b5ea33d768dba099c3339
455
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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.
|
||
|
|
af7874546a |
feat(projects): per-file print progress and complete-sets tracking (#1897)
Projects made of many distinct files that each need N prints (e.g. 13
plates x 10 sets = 130 prints) only had aggregate progress. Finding out
"how many times have I printed plate_7?" meant reading the Activity
Timeline line by line, unusable at 130 events.
Projects now take an optional Copies per File target. Every printable
file in the project's linked folders shows an X / N badge with a mini
progress bar (gray not started, amber in progress, green done), and the
progress card gains a Complete Sets bar - the minimum per-file count,
i.e. how many finished assemblies can be shipped right now. Without the
target, printable files show a plain printed-count badge.
Counting matches the aggregate project stats: completed runs only,
served by a new /projects/{id}/file-progress endpoint. Runs attribute
to a file via a new library_file_id stamp on queue-dispatched archives,
falling back to content hash and then filename for historical rows.
Also fixed: files queued from a project-linked File Manager folder now
inherit that project, so their prints count toward project statistics -
previously only prints started from the project page were attributed.
Test-harness fix along the way: the test suite's get_db override never
committed, unlike production get_db, so endpoints relying on the
request-scoped commit silently lost their writes in tests. The override
now mirrors production commit/rollback semantics.
|
||
|
|
eae5359fbc |
feat(printers): show AI failure detection state on printer cards (#1546)
The live Obico classification was only visible under Settings -> Failure Detection, so tracking how detection matched an ongoing print meant flipping between the Printers screen and Settings. Each printer card's badge row now shows an AI badge whenever detection is enabled for that printer, like the other health badges: gray Idle while no print is being watched, then green Safe, amber Warning, or red Failure while a print is actively monitored. The tooltip carries the current smoothed score; clicking jumps to the full detection status and history in Settings. Printers excluded from the monitored subset show no badge. Served by a new lightweight /obico/printer-status endpoint readable with printer permissions alone - it exposes only the enabled flag, the monitored-printer set, and per-printer classification, keeping ML URL and other configuration behind the existing settings-gated endpoint. |
||
|
|
62ba751278 |
feat(notifications): Bark notification provider (#1495)
Bark is the open-source, account-free iOS push app (self-hostable
via bark-server). Configure with just the device key from the app;
the server URL defaults to the official api.day.app relay and
accepts a self-hosted instance. Optional settings: notification
Group, Sound, and iOS Interruption Level - Time Sensitive breaks
through scheduled summaries, Critical bypasses Silent mode and
Focus, Passive delivers silently.
bark-server can wrap failures in an HTTP 200 body ({"code": 400}),
so the sender checks the body code as well as the HTTP status.
Unknown interruption levels are dropped rather than forwarded.
|
||
|
|
d68724c689 |
feat(stats): energy usage in cost records and trends (#1432)
The Most Expensive record on the Statistics page ranked prints by filament cost alone, ignoring the per-print energy cost Bambuddy already measures via an attached smart plug. It now ranks by filament + measured energy cost; prints without smart-plug data compete on filament cost alone, as before. Filament Trends gains an Energy Over Time chart: kWh per day (per hour for ranges of a week or less, per week for long ranges), with the range's total kWh and energy cost in the header. The chart only renders when the selected range contains measured energy data, so setups without smart plugs see no change. The /archives/slim stats feed now carries each run's energy_kwh / energy_cost from print_log_entries. Translated in all locales. Covered by backend and frontend tests. |
||
|
|
1bdd7d224a |
fix(library): sort File Manager by real filesystem mtime, recursively (#2680)
The folder tree's "sort by recent activity" and the file pane's date sort put external (mapped/NAS) files in a near-random order instead of ls -t's newest-first. Nothing captured the files' on-disk mtime: the sort keyed off the DB updated_at/created_at, which for a bulk external scan is the same scan instant for every row, so a whole block tied and sorted arbitrarily; only rows Bambuddy had later touched individually looked "partially right." The tree also bubbled up only immediate child-file activity, so a file added deep in a subtree never lifted its parent folders. - Add nullable fs_modified_at to LibraryFile and LibraryFolder (dialect- branched migration, mirroring the #2615 dispatching_at pattern). - External scan records each file's and directory's real os.stat().st_mtime and refreshes it on every re-scan, so a file edited over the mount re-sorts and existing installs backfill on the next scan. - list_folders computes each folder's activity as a recursive newest- descendant roll-up (post-order), so a fresh deep file lifts every ancestor. - Folder tree sort and the file pane's date sort now use the real mtime, falling back to created_at for managed uploads with none. - New toolbar toggle shows/hides each item's last-modified date in the right pane (grid + list), with strings in all locales. Store the mtime as naive UTC to match the other timestamp columns so activity comparisons never mix naive and aware values on either dialect. Covered by integration tests (mtime capture, re-scan refresh, deep-file recursive bubble, folder mtime) and a frontend test proving fs_modified_at is preferred over created_at. |
||
|
|
800c45536e |
fix(scheduler): pin the force-color variant when selecting the AMS slot (#2650)
Follow-up to
|
||
|
|
56accd24de |
fix(smart-plugs): don't blank printer state when an accessory plug switches off (#2629)
An end-of-print auto-off on a plug that powers a filter fan marked the linked printer offline and forced its state to "unknown". The mark was unrecoverable: connected heals on the next MQTT message but state does not (only frames carrying gcode_state rewrite it, and steady-state push_status frames are partial), so the printer stayed "unknown" until a manual Force Refresh and the queue never dispatched to it again. The offline mark is now an explicit presumption: mark_power_off records the state it overwrites and _on_message undoes it as soon as the printer sends another report on its own topic, since inbound traffic proves the power was never cut. A reconnect discards the saved state, so a genuine power cut is unaffected. Each plug also gains a controls_printer_power flag (default true, backfilled) that gates all five power-off paths, and the queue's power-on step now picks the flagged plug instead of whichever linked plug came first. |
||
|
|
2e45893dd5 |
feat(print-options): add "Auto" state to bed levelling, flow & nozzle-offset calibration
Bed levelling, flow calibration, and nozzle-offset calibration were on/off only, so the sole way to run bed levelling was to force a full level before every print. Bambu Studio has always offered a third "Auto" state that lets the printer skip the calibration when it was done recently -- the state most users actually want. Make these three options tri-state (off/on/auto), defaulting to auto, and leave vibration/layer-inspect/timelapse as on/off (Bambu Studio exposes no auto for those). Wire encoding follows Bambu Studio's source exactly: each option sends a JSON bool (true only for "on") plus a companion int -- off=0, on=1, auto=2. The bool fields stay booleans (the #1478 H2S regression); only the companion int widened from {0,1} to {0,1,2}. #1721's observation that stage 8/39 stays queued when sending 2 is the auto contract (queued, skipped at runtime if recent), not a broken "off". - schemas: TriState = Literal[off/on/auto] with a BeforeValidator coercing legacy bool / 0-1 / true-false so old clients and un-migrated rows validate - model + migration: boolean columns -> String; SQLite via column affinity + data backfill, PostgreSQL via ALTER COLUMN TYPE guarded on information_schema (verified on both dialects); settings rows normalised true/false -> on/off - MQTT: start_print takes the tri-state strings and emits the paired bool+int - Virtual Printer: reconstructs the slicer's auto/on/off from the int companion (auto_bed_leveling / extrude_cali_flag) in both capture paths - frontend: CalibrationMode type; off/auto/on segmented controls in the print dialog, queue bulk-edit, and Settings -> Workflow; calibrationMode_* strings in all 11 locales |
||
|
|
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.
|
||
|
|
c469aa3407 |
feat(slicer): add "slice as designed" mode honouring a 3MF's embedded settings (#2611)
Server-side slicing always applied the picked printer/process/filament triplet via --load-settings, which overrides the designer's embedded project_settings.config — so a MakerWorld model set up for 5 walls came out at the picked profile's default 2. That override is correct for re-slicing a design onto your own printer/AMS, but there was no way to slice a file the way its author configured it. SliceModal now offers a "Use the file's built-in settings" checkbox when the source 3MF carries embedded settings AND the picked printer matches the design's target model. It routes to the existing embedded-settings slice path (previously only a crash fallback), so walls/infill/filament come from the file. Ticking it locks all four preset dropdowns — printer included, since it's unused on this path and changing it would drop the match and hide the toggle. The printer-match gate stops embedded settings being honoured across models (wrong bed); there is no cross-printer re-targeting on this path. - schema: use_embedded_settings on SliceRequest - route: embedded_mode branch; crash-fallback guarded against re-running - frontend: gated checkbox locking all four dropdowns, resets on mismatch - 2 i18n keys across all 11 locales - tests: backend (flag skips triplet / ignored for STL) + frontend (toggle offered on match, locks dropdowns + sends flag / hidden on mismatch) |
||
|
|
83a7b75b14 |
fix(queue): persist selected plate to the archive; reconcile archive on offline stop (#2603)
A print queued from a specific plate of a multi-plate 3MF showed as Plate 1 in Print History after cancellation: the archive derives its plate from the filename, but a whole multi-plate 3MF uploads under one name with no plate suffix, so the parser defaulted to plate 1 and nothing copied the queue item's plate_id onto the archive (which had no plate field). Add a nullable print_archives.plate_id, copy it from the queue item at dispatch (archive- and library-file paths), expose it in the archive API, and render it in Print History. A startup backfill copies the plate onto existing archives from their linked queue rows. Column add + backfill are identical on SQLite and Postgres. Also fix a related lifecycle bug: stopping a printing item while the printer was offline left the linked archive stuck at "printing" (queue row cancelled, but no MQTT completion ever arrives to reconcile the archive). The offline-stop path now closes the archive out directly; the online path still defers to the MQTT completion event. |
||
|
|
e77e10896f |
feat(ams): name the expected slot when a paused print hits an AMS runout (#2587)
The firmware's runout HMS text says "insert into the same AMS slot", which is wrong under AMS Filament Backup: the firmware won't re-accept the depleted slot and advances to the next compatible one. Bambuddy parsed print.ams.tray_now only and dropped tray_tar/tray_pre, so the expected slot never reached the UI. Capture tray_tar/tray_pre on PrinterState and, while paused, resolve them to global tray IDs (expected_tray/previous_tray) on both the REST and WebSocket status payloads via a shared resolver: single-AMS passthrough, multi-AMS snow-mapping resolution, AMS-HT/external passthrough, and an honest null when the slot can't be placed. The AMS graphic highlights the expected slot (amber) and the ran-out slot (red); the HMS modal re-describes runout codes to name both, falling back to "check the printer" when unresolved. Runout copy translated in all 11 locales. Reporter @Jostxxl confirmed tray_pre=1/tray_tar=2 during the pause (ran out in Slot 2, printer expected Slot 3). |
||
|
|
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. |
||
|
|
a6e7d671f2 |
fix(jog): stop disabling firmware endstops; warn that limits aren't enforced (#2579)
Manual jog could drive an axis past its travel limit into a collision. Instrumenting the exact G-code to an H2D showed Bambuddy sending a clean move at the limit (G91 / G1 Z-1.00 F600 / G90, no M211) that the printer ran straight past, while its own touchscreen refuses the identical move. This is a Bambu firmware bug: soft endstops are not enforced on G-code received over MQTT, and no axis position is reported, so the move cannot be clamped firmware- or client-side from position. Two changes: (1) jogs no longer wrap moves in M211 S0/S1 — that disabled the firmware's soft endstops globally, breaking even the touchscreen's limits until a power cycle; a bare move keeps the touchscreen protected. (2) The jog panel shows a prominent warning that travel limits are not enforced during manual moves due to the firmware bug. Client-side dead-reckoning enforcement is tracked separately. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
6358e9544e |
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.
|
||
|
|
006c3113a0 |
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). |
||
|
|
b71d486058 |
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. |
||
|
|
61a7f2e4ac |
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.
|
||
|
|
b23cb69a66 |
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). |
||
|
|
3ef197e4e0 |
feat(slicer): Pipelines — multi-copy + class targeting + fanout + runs dashboard + retry-failed + WS updates (#1425 PR C — completes the v3 design)
PR A/B turned the slice modal's preset bundle into a one-click dispatch
with a pinned target printer. PR C closes the original issue: operators
type in a number of copies, Bambuddy slices once and distributes prints
across a fleet per the pipeline's chosen fanout strategy. A new dashboard
surfaces every run with filters, expandable per-copy status, cancel,
and retry-failed-copies. WS pushes keep everything live.
Backend
- copies field on POST /run, capped by new pipeline_max_copies setting
(default 50, hard cap 1000). PipelineRun.parent_run_id chains retries.
- SlicerPipelineUpdate accepts target_kind (specific_printer /
printer_class), target_model_class, fanout_strategy.
- Eligibility matcher branches: class-targeting enumerates matching
Printer rows, runs per-printer checks via a status_lookup closure,
returns printer_reports[]. New issue kinds: no_class_matches,
class_not_set.
- _pick_assignments distributes copies per strategy:
- max_parallel: target_model set, printer_id None — scheduler picks
- round_robin: copy i → eligible[i % N], fixed printer_id
- fill_one_first: all copies pinned to eligible[0]
All three reuse the slice-once path through slice_dispatch.enqueue.
- New routes:
- GET /pipeline-runs (paginated, filterable by pipeline + status)
- POST /pipeline-runs/{id}/retry-failed (creates child run with
copies = failed+cancelled count, parent_run_id set)
- Cancel cascades to all N queue entries (only pending/queued)
- _roll_up_run_status computes run-level status from per-job statuses;
introduces partial_failure for "some completed, some failed".
- ws_manager.broadcast_to_user emits pipeline_run_updated on every
state transition with the full materialised response.
Frontend
- Pipeline editor: target_kind radio + class picker (filtered to
installed models) + fanout-strategy radio. Read-only row shows
"X1C · Round robin" for class pipelines.
- RunWithPipelineModal: copies number input bounded by
settings.pipeline_max_copies. Accepts class-targeted pipelines.
- Settings → Workflow → Queue & Dispatch: new "Slicer Pipeline limits"
card with the max-copies input.
- New /pipelines/runs dashboard page (sidebar entry, gated on
pipelines:read). Two-filter dropdown, 25-per-page pagination, per-row
expandable to job list, Cancel + Retry-failed buttons.
- useWebSocket case for pipeline_run_updated invalidates both
pipeline-runs-all and pipeline-runs/{id} query keys.
|
||
|
|
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. |
||
|
|
d4ad41d850 |
fix(hms): action buttons actually reach the printer (#1830)
Three distinct bugs combined into one user-facing failure: clicking
Stop / Problem-solved-and-resume / Ignore-and-resume returned 200 OK
but the printer didn't act, modal stayed up, print stayed paused.
Verified by injecting candidate command shapes on device/<sn>/request
against a live H2D paused on a wrong-plate HMS (print_error=0x05008051).
(1) hms_resume / hms_stop dispatched the "err"-bearing shape that
BambuStudio doesn't actually send; Bambu firmware silently rejects it.
Both now send the plain shape ({"print":{"command":"<x>","param":"",
"sequence_id":"0"}}). PAUSE -> FAILED in 1.7s for stop, PAUSE -> RUNNING
in <2s for resume.
(2) IGNORE_RESUME mapped to idle_ignore, which is BambuStudio's
"dismiss a warning" command and only works for non-pause warnings.
hms_ignore now branches on state.state == "PAUSE": paused -> plain
resume; not-paused -> idle_ignore with the full-length err.
(3) 64-bit hms[]-array faults were truncated to a non-matching err.
short_code in _parse_status discarded 32 of the 64 identifier bits, so
the firmware didn't match it to the active fault. HMSError.full_code
now carries the canonical hex identifier (16 chars for hms[] faults,
8 chars for print_error faults). Catalog lookup tries 16-char first,
falls back to 8-char. HmsActionBody.print_error pattern relaxed to
^[0-9A-Fa-f]{8}([0-9A-Fa-f]{8})?$.
(4) execute_hms_action returned publish-success as success, masking
every silent-rejection bug above as 200 OK. Route now snapshots
(state.state, len(state.hms_errors)) before dispatch, awaits
HMS_ACTION_ACK_WAIT_SECONDS (default 2.5s, module-level so tests
override), and returns 502 with "Printer did not acknowledge HMS
action within 2.5s" if state didn't move.
|
||
|
|
3ddf8d847e | [Feature]: HMS Actions (#1743) | ||
|
|
4c67d8a4e1 | feat: Unify print dispatch through the scheduler (#1625) | ||
|
|
70857af393 |
feat(auth): SSO autologin + disable local username/password login (#1589)
Adds a global local_login_enabled setting plus a per-provider is_autologin flag on OIDCProvider so operators who run their own SSO enabled, or if the calling admin has no UserOIDCLink — either would lock everyone out. App-layer invariant: at most one provider can carry is_autologin; setting it on one clears it on every other. /auth/advanced-auth/status surfaces both new fields so the LoginPage decides UI in one query. The env-var bypass flips the reported local_login_enabled back to true so the SPA matches what the route will accept. |
||
|
|
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.
|
||
|
|
8d6f701f1d |
feat(drying): continue drying while printing + gate rotate-spool when tray loaded (issue #1816)
Continue Auto-Drying while a print is running on capable hardware. New Settings > Print Queue > "Continue drying while printing" toggle (default OFF). Extends _check_auto_drying in print_scheduler.py to evaluate running printers when supports_drying_while_printing(model, firmware) returns true. Strict allowlist verified per Bambu wiki release notes for "Print While Drying" / "printing while filament is drying": H2D 01.03.00.00+, H2C/H2S/P2S/H2D Pro 01.02.00.00+, X2D/A2L 01.01.00.00+, X1C 01.11.02.00+. P1*, A1, A1 Mini, X1 (non-C), X1E intentionally excluded. Mid-print drying temperature is capped at max(40, preset_temp - 5) to protect spools from heat damage inside the hot enclosure during a print, matching Bambu's own "lower drying temperature during printing" guidance. Rotate-spool toggle in the drying popover is now disabled when any tray in the targeted AMS has filament threaded into the feed tube (tray.state === 11). The whole AMS rotates as one mechanism, so a single loaded slot locks the entire unit. Previously the toggle was always clickable and the firmware rejected with dry_sf_reason=[3] (ConsumableAtAmsOutlet) after the click. The first cut keyed on the printer-level tray_now but missed the H2D's typical post-print state where tray_now resets to 255 while filament stays in the tube — the per-tray state field reports it correctly. Submission also clamps rotateTray off so a stale-true state from a previous AMS can't leak through. Backend: supports_drying_while_printing in printer_manager.py covers display names and internal SSDP/MQTT codes (O1D, O1E/O2D, O1C/O1C2, O1S, N6, BL-P001, N7, N9). New print_drying_enabled boolean in settings schema. Frontend: toggle on SettingsPage, gate + clamp on PrintersPage drying popover using existing amsData cache. i18n: 3 new keys x 11 locales, no English fallback. Tests: 7 cases on the gate matrix (TestSupportsDryingWhilePrinting), 4 cases on the scheduler mid-print path (TestMidPrintDrying), 9 cases on the rotate gate state transitions. Full backend pytest -n 30 green (4251/4251), ruff clean, frontend npm run build clean, i18n parity 5355 leaves per locale. |
||
|
|
6c5b40dd57 | [Fix] Forecasting: Group spools by color and rework UI (#1814) | ||
|
|
2fe9896917 |
fix(queue): close #1818 — Resume after failure clears the gate
Single failure on a printer with require_previous_success queue items
permanently skipped every downstream + every new item — the
_check_previous_success lookback always walked back to the original
failed row (skipped is excluded from the lookback), and no code path
could dismiss that failure.
Three pieces:
1. PrintQueueItem.gate_acknowledged Boolean column (default False).
SQLite/Postgres-safe ALTER, dialect-branched DEFAULT.
2. _check_previous_success skips rows where gate_acknowledged=True so
acknowledged failures walk past the lookback. Fresh post-resume
failures still gate independently.
3. POST /api/v1/queue/printer/{printer_id}/resume — gated on
QUEUE_UPDATE_ALL — acknowledges failed/aborted items for that
printer AND restores items where
status='skipped' AND error_message='Previous print failed or was
aborted' back to pending in one transaction. Returns
{acknowledged, restored}.
Frontend banner above the active Queue tab surfaces blocked printers,
fires a warning-variant ConfirmModal, and shows a precise toast on
success.
|
||
|
|
fb3821630f |
feat(inventory): batch / mass edit on the Filament tab (#1795)
Bulk operations on the Inventory page in both built-in and Spoolman modes.
Reporter wanted ten-of-the-same-spool edits without ten round-trips through
the per-spool editor.
Frontend
- New checkbox column on the inventory table (header / row / group). Sticky
toolbar appears when at least one row is selected with Edit / Print labels /
Reset usage / Archive (or Restore in the Archived tab) / Delete / Clear.
Selection clears on any filter / tab / search change so the count can't
drift from what is on screen.
- BulkEditSpoolsModal is a three-state-per-field form. The user opts in per
field by ticking its checkbox or just typing into it; only ticked + non-
empty fields are sent. Clearing fields in bulk is intentionally NOT
supported per the issue discussion.
- A new SearchableSelect renders all categorical fields (material, sub-type,
brand, category, slicer preset name, slicer filament, storage location)
with the same dropdown pattern the per-spool editor uses - text input +
chevron + filtered button list, click-outside / Escape closes. No native
select anywhere in the modal. Options merge the canonical constants from
spool-form/constants.ts with whatever already exists in the user's
inventory. Slicer-preset dropdowns fetch the same sources as the per-spool
form (Bambu Cloud + Orca Cloud + local + built-in) through buildFilament
Options() and three useQuery calls gated on isOpen.
- onSuccess handlers surface three outcomes: all-succeeded (green toast),
partial-success (yellow toast with ok / failed counts), all-failed (red
toast that keeps the selection and modal open so the user can retry).
The first cut silently dropped errors / not_found arrays - audited and
fixed before merge.
- Invalid rgba hex is flagged inline with a red border + helper text and
the Apply button is gated on a hasDroppedTickedField guard, so silently
dropping a ticked field is no longer possible.
- bulkResetConsumedCounterMutation.onSuccess now closes the confirm modal +
clears selection, matching the other three bulk mutations.
Backend
- Four new endpoints per inventory mode (eight total):
POST /api/v1/inventory/spools/bulk-update INVENTORY_UPDATE
POST /api/v1/inventory/spools/bulk-delete INVENTORY_UPDATE
POST /api/v1/inventory/spools/bulk-archive INVENTORY_UPDATE
POST /api/v1/inventory/spools/bulk-restore INVENTORY_UPDATE
POST /api/v1/spoolman/inventory/spools/bulk-* FILAMENTS_UPDATE
- Built-in update runs the same prepare_internal_spool_payload(...) +
weight_used / weight_locked auto-stamp as the per-spool PATCH.
- Spoolman update loops the per-spool update_spool route function so the
filament re-linking / extra-dict / extra-lock / shared-filament rules
stay byte-identical to single-spool edits.
- Per-spool failures inside the batch are collected. Spoolman bulk-delete /
archive / restore now catch non-HTTPException too (matches bulk-update) -
a mid-batch httpx.ConnectError or TimeoutError no longer aborts the route
with a 500 and skips the WS broadcast.
- Both modes broadcast a single inventory_changed WS event at the end of
the batch.
|
||
|
|
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 ?? []. |
||
|
|
5cbefca6a0 |
feat(file-manager): recursive subfolder search + per-folder markdown description panel (#1268)
Reporter (@zumik3-del, seconded by @unLieb) asked for three File Manager
improvements: recursive search, tags, and a markdown preview side panel.
This commit ships the two scoped ones; tags is held back gated on the
"give the issue a thumbs up" interest check Martin posted on the issue
because it's a much larger surface (M2M schema, CRUD endpoints, tag UI +
filter + autocomplete + i18n for the management surface) and isn't the
right call without a real demand signal.
1) Recursive search inside the selected folder.
Until now, selecting "Toys" and typing "robot" only found files
directly in Toys/ — anything under Toys/Cars/Race/ stayed invisible.
The page's client-side filter ran over a server-narrowed listing
(/library/files?folder_id=X is strict equality on folder_id), so the
client filter couldn't see what the listing never loaded.
list_files (backend/app/api/routes/library.py:1729+) gains a
recursive=true query param. When combined with folder_id, the route
walks library_folders.parent_id via a recursive CTE rooted at the
requested folder and returns every descendant folder's files in one
query. Recursive CTEs work on both SQLite >=3.8.3 (2014, well below
Bambuddy's runtime floor) and Postgres without dialect branching.
Default off so the existing folder-browsing call sites (Project /
Archive detail, the FE's no-search case) keep their narrow scope.
FE opts in only when both a folder is selected AND searchQuery is
non-empty (FileManagerPage.tsx — derived as searchExpandsSubfolders,
threaded through the useQuery key so the cache invalidates on
toggle). Small "Including subfolders" caption renders under the
search input when active so the user understands why a file from two
levels deep showed up.
2) Per-folder markdown description panel.
New endpoint GET /library/folders/{folder_id}/readme returns the
first .md file in the folder as {filename, content, truncated}.
Selection prefers README.md / readme.md / description.md
(case-insensitive via func.lower(filename) LIKE '%.md' + an
in-Python stem-preference sort), falls back to the
alphabetically-first *.md otherwise. 404 when no markdown is present
so the FE can hide the side panel — non-users pay no UI cost.
Bytes are clipped at 512 KiB (_README_BYTES_CAP) with a truncated
flag so the panel can warn the reader. UTF-8 decode uses
errors="replace" so one bad byte never blanks the panel.
New FolderReadmePanel.tsx fetches on folder-select and renders via
react-markdown@9 + remark-gfm@4 (tables, strikethrough, task lists).
Collapsible (default expanded), max-height 24rem with internal
scroll. react-markdown 9 doesn't render raw HTML by default — no
dompurify needed. Links open in a new tab with rel=noopener
noreferrer. Tailwind has no typography plugin in this project so
per-element components map h1/h2/h3/p/ul/ol/code/blockquote/table
to explicit utility classes that match the rest of the app.
Scope and permissions.
Both endpoints reuse the existing LIBRARY_READ_ALL / LIBRARY_READ_OWN
ownership-aware pair, so a viewer-tier user with read_own only sees
their own files in recursive listings and can only fetch the README of
folders containing their own files. No new permission, no DB migration.
The recursive CTE is a single SQL query — no N+1, no per-folder
round-trip, scales to deeply-nested model libraries.
|
||
|
|
4206d675eb |
feat(notifications): dedicate AI Failure Detection notification event (#1794)
Split Obico failure-detection dispatch out of the multiplexed
on_printer_error event onto its own on_ai_failure_detection event so
users can subscribe to AI alerts without also enabling HMS hardware-
error pages, and so the discoverable label "AI Failure Detection" is
what subscribes them rather than the unrelated "Printer Error" toggle.
New column on notification_providers (default False, branched
SQLite/Postgres migration), new notification_service.on_ai_failure_detection
method, new ai_failure_detection template, obico_actions._notify swap.
Frontend gets a summary badge, a toggle row with description, and ntfy
priority surfacing. 14 new tests pin the routing + the regression guard
("Printer Error" alone must NOT receive AI notifications now). 11 locales
covered.
Existing providers keep working: HMS hardware errors continue to ride
on_printer_error unchanged; users who want spaghetti alerts opt in via
the new toggle.
|
||
|
|
4d16faed76 |
feat(file-manager): sort folder tree by recent activity (#1770)
Reporter has a lot of nested cad / slicer directories and wanted "folders that just got a new 3MF" surfaced without scrolling the alphabet. Tree was always alphabetical; LibraryFolder.updated_at only bumps on rename / move, not on file-add inside the folder. Backend exposes latest_activity_at = max(folder.updated_at, max(immediate-child file.updated_at)) on FolderResponse + FolderTreeItem. The /folders tree route picks up a sibling func.max(updated_at) group-by alongside the existing file-count subquery; the by-project / by-archive / single-folder routes collapse count + max into one trip. Recursion across subfolders is intentionally not computed - bubbles immediate parent only, keeps the query a single GROUP BY rather than a recursive CTE. Frontend adds a folder-sidebar sort dropdown (By name / By recent activity) plus an asc / desc arrow, persisted in localStorage. sortedFolders memo applies the comparator recursively so order is consistent at every depth. Empty folders fall back to name within the activity bucket so they never elbow a recently-used folder to a random position. Both the desktop sidebar and the mobile selector consume the sorted list so order is identical across breakpoints. External folders: LibraryFile rows are created for scanned external files too, so the aggregate works on them - but the timestamp reflects last scan, not filesystem mtime. Documented in the wiki. Same change also fixes File Manager list-view column alignment: header and body were sibling grids with min-content as the trailing column, computed independently. Header empty trailing div resolved to 0; body action strip to ~220px. Different trailing widths gave the 1fr Name column different remaining space, shifting every fixed column to its right. Replaced min-content with fixed 220px in both auth-on / auth-off grid templates. |
||
|
|
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).
|
||
|
|
36a16b8ae4 |
feat(printers): per-printer Maintenance Mode toggle (#1476)
Operator-flipped out-of-service state per printer for three scenarios: parallel Bambuddy installs (dev + prod where the printer rejects all but one MQTT client), printers under repair, and temporary suspension. The backend Printer.is_active gate has shipped since day one and is already honoured by every consumer — MQTT (printer_manager), queue dispatch (print_scheduler, print_queue), metrics, scheduler, picker, backup, maintenance dashboard. The missing piece was UI exposure. Three entry points to flip is_active: - Three-dot overflow menu (Enter / Exit maintenance mode, wrench icon) - Exit button inside the in-card amber panel - Checkbox in EditPrinterModal Card UI: expanded mode shows an amber panel (Wrench + "In Maintenance" + subtitle + Exit) where the cover/progress container would normally render — same height, no layout shift. Compact mode shows an amber pill in place of the progress bar. Header pill swaps Connected/Offline for "Maintenance" and the diagnostic CTA is suppressed (deliberate state, not involuntary offline). HMS / Queue / Firmware pills fall away naturally via the existing status?.connected gates. Mid-print entry (RUNNING / PAUSE) triggers a confirmation dialog — disconnecting MQTT mid-print stops progress tracking and completion notifications for the in-flight job. Idle / FINISH / FAILED skip the dialog and toggle directly. Scope: no backend change, no new permission, no behaviour change for any other consumer. PrinterCreate.is_active?: boolean added to the TypeScript surface so the field flows through api.updatePrinter. |
||
|
|
25a23eadd7 |
fix(updates): switch Windows installer installs to release-asset update flow
In-app "Install Update" on Windows installer installs failed with "Could
not find git executable" because (1) _find_executable's fallback paths
are Unix-only, and (2) the installer stages backend/ via shutil.copytree
so there is no .git directory — even with Git for Windows installed, the
fetch would die on "not a git repository". Adding Windows paths would
only have changed which error users saw.
Switches the Windows installer path to a fourth update_method
("windows_installer") that mirrors the existing docker / ha_addon
branches — surface a link to the release .exe and let the user re-run
the installer, matching the Discord / Spotify Windows update model.
Backend:
- New _is_windows_installer_install() — true iff sys.platform == "win32"
AND no .git in app_dir, so Windows devs with a real git clone keep
the git path.
- New _find_windows_installer_asset() picks the matching release asset
(prefers versioned bambuddy-<ver>-windows-x64-setup.exe, falls back
to the unversioned alias on non-daily tags).
- /updates/check now returns is_windows_installer / update_method /
installer_download_url.
- /updates/apply short-circuits with a friendly message after the
existing HA / Docker guards — defense in depth, the frontend swaps
the button so the POST should not fire on Windows.
Frontend:
- UpdateCheckResult extended with the new fields and 'windows_installer'
in the update_method union.
- SettingsPage renders a Bambu-green styled <a target="_blank"
rel="noopener"> between the Docker snippet and the in-app Update
button, with installer_download_url falling back to release_url then
the tag page so the link is never broken.
- applyUpdateMutation onSuccess toast guard extended to treat
is_windows_installer the same as HA / Docker.
|