mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
d6a31393976491d4d1b2ea2a5fbfc412b9a4e985
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d6a3139397 |
fix(frontend): emit relative asset paths so SPA loads under any subpath (#1195)
Vite's default base of '/' baked absolute asset URLs into the built
index.html (/assets/..., /manifest.json, /img/..., /sw-register.js),
so any path-prefixed reverse proxy (Traefik, nginx subpath, Cloudflare
Tunnel with path routing) served the SPA as a blank white page — the
browser requested assets from the host root and got HTML or text/plain
back, triggering MIME-mismatch errors on every stylesheet/script.
Set base: '' in vite.config.ts so the HTML transform emits relative
URLs everywhere. Update public/sw-register.js to register('sw.js')
(relative) so SW scope auto-pins to whatever subpath the document
loaded from.
Out of scope: API_BASE in client.ts is still absolute. The supported
HA embedding path remains Webpage panel + TRUSTED_FRAME_ORIGINS, not
HA Ingress (subpath-aware SPA bootstrapping has too many failure modes
around PWA scope, push subscriptions, and deep-link reloads to take on
in core). Documented explicitly in docker.md.
Reported by @Spegeli, follow-up to #1167.
|
||
|
|
875d80ad3c |
fix(mqtt): lift paho inflight ceiling to prevent QoS=1 session wedge (#1164)
paho's default max_inflight_messages=20 silently fills on Bambu's broker after ~16-20 cumulative commands per session, leaving publish() returning success while packets sit in paho's internal queue. force_reconnect heals it because the inflight queue is per-session, but it costs one wasted user action to trigger. Lifting the ceiling to 1000 keeps QoS=1 untouched (deliberately chosen for cross-model reliability — A1, P1S, X1C, H2D, P2S, X2D all need it) and removes the inflight queue as the bottleneck without changing wire-protocol behaviour. The 0.2.4b2 watchdog reconnect stays as defence-in-depth. Diagnosis credit: RosdasHH's QoS=1/0/2 bisect on #1164. |
||
|
|
b02350d423 |
fix(security): allow iframe embedding from trusted origins via env var (#1191)
Bambuddy ships strict anti-clickjacking headers (X-Frame-Options: SAMEORIGIN + CSP frame-ancestors 'none') by default. Internet-exposed deployments need this; same-LAN HA Webpage-panel users do not, and SAMEORIGIN is port-strict so HA on :8123 + Bambuddy on :8000 always fails. azurusnova hit exactly that case. Add TRUSTED_FRAME_ORIGINS env var (comma-separated scheme://host[:port]). When set, drop X-Frame-Options entirely (modern browsers honor frame-ancestors and the legacy ALLOW-FROM syntax is deprecated / inconsistent across vendors) and emit "frame-ancestors 'self' <list>" on every CSP-bearing route. Origin validation is strict: only http(s), no paths, no query/fragment, no wildcards. Bad entries get a warning and are dropped — startup never fails. Default behaviour (no env var) is unchanged: X-Frame-Options: SAMEORIGIN + frame-ancestors 'none', so existing Docker / bare-metal deployments are not affected. |
||
|
|
31577b8d2b | Post work PR #1176 | ||
|
|
3320c7fd45 |
feat(printers): AMS slot Load / Unload from the printer card (#891)
The ams_load_filament / ams_unload_filament MQTT primitives existed
in bambu_mqtt.py but were unused — no HTTP route and no UI. Surface
both as POST /printers/{id}/ams/load?tray_id={int} and
POST /printers/{id}/ams/unload, gated on PRINTERS_CONTROL.
Wire them into the existing AMS slot popover (next to "Re-read RFID")
and add a popover wrapper on the external spool slot which had none.
Hidden while the printer is RUNNING, mirroring the RFID re-read
gating. Both buttons enabled when permission is granted; the printer
no-ops gracefully if there's nothing to do (matches BambuStudio).
Dual-extruder H2D Ext-R support is the trickier piece. The existing
ams_load_filament(254) capture came from a single-extruder printer
and used slot_id=254, curr/tar=-1. Captured the Ext-R command from
BambuStudio fresh: it sends ams_id=255, slot_id=0 (the right
extruder index, NOT a slot index), target=255, and curr/tar = the
actual right-nozzle temp (read from state.temperatures["nozzle_2"],
falling back to 215 °C if cold so the printer doesn't reject the
command on a nonsensical temp). Added that as a new branch in
ams_load_filament; the existing tray_id=254 branch is preserved
verbatim — no risk of regression on single-external setups.
|
||
|
|
459cfdc51f |
fix(virtual-printer): queue mode pins per-slot type+color so scheduler can match colour (#1188)
Edward's diagnosis was exact: the manual /print-queue/ POST extracts filament requirements from the 3MF and writes required_filament_types + filament_overrides + ams_mapping onto the queue item, but the VP queue-mode write path skipped all of that. Net effect: scheduler reached its model-only-matching fallback and auto-dispatched onto whatever printer was free regardless of loaded colour. Extract the scheduler's existing _get_filament_requirements 3MF parser into a shared helper so the VP path can reuse it. VP's _add_to_print_queue now populates required_filament_types unconditionally (cheap; helps the scheduler reject obvious type mismatches) and writes filament_overrides with force_color_match: true per consumed slot when a new per-VP queue_force_color_match toggle is on. Default off to preserve current behaviour for upgraders. UI: new toggle on VirtualPrinterCard, mode-gated to print_queue, mirroring the existing auto-dispatch toggle. i18n: en + de translated, other 6 locales seeded with English copy. Schema: one nullable column on virtual_printers (queue_force_color_match BOOLEAN, default 0/FALSE). 11 new backend tests (8 for the extracted parser, 3 for the VP write path) + 6 new frontend tests (toggle render gating, default state, click posts queue_force_color_match in update body). Existing scheduler tests pass against the refactored helper. README, CHANGELOG, website features page, and wiki virtual-printer page all updated. |
||
|
|
d81040607e |
fix(api-keys): slice + slicer-presets routes resolve cloud token via key owner (#1182 follow-up)
turulix's headless slicing pipeline got cloud preset IDs from /api/v1/cloud/settings (the /cloud/* gate from #1182 worked), but slicing those IDs via POST /library/files/{id}/slice failed with "no Bambu Cloud session is stored" — the slice route lives on a different router, never saw the api_key_owner stash, and _resolve_cloud fell through to the empty auth-disabled global Settings token. Add a permissive route-level dep that returns the API key's owner when the key has the cloud scope and None otherwise (never raises), so non-/cloud/* routes can opt in without breaking the local-preset path. Wire it into POST /library/files/{id}/slice and GET /slicer/presets (same root cause, would hit any UI proxied through an API key). The route picks current_user or api_key_cloud_owner before deriving user_id. Auth gate's None-return for API keys is unchanged — keeping the owner-resolution scoped to the routes that actually need a cloud token prevents scope creep into routes that fence on ``current_user is None``. |
||
|
|
592ec44705 | chore(mqtt): log slicer-launched project_file payload for FTS routing diagnostics (#1162) | ||
|
|
82a593de95 |
fix(projects): portal-mounted hover preview for cover thumbnails (#1155)
@smandon flagged the 40×40 cover thumbnail as too small to recognise the print and asked for a click-to-enlarge full preview. Enlarging the thumbnail itself would shift the card grid layout, so keep the small thumbnail and show a 384×384 hover popover with the full image in ``object-contain`` rendering (so tall MakerWorld photos aren't cropped to a square). Why a portal: ProjectCard carries ``overflow-hidden`` (rounded corners + color accent bar), so any in-tree popover gets clipped the moment it extends past the card. Rendering via ``createPortal(..., document.body)`` escapes every ancestor clipping context, and ``position: fixed`` with measurements from ``getBoundingClientRect()`` keeps the popover pinned next to the thumbnail regardless of grid position. ``pointer-events-none`` on the popover so it can't intercept hover and create a flicker loop; ``z-[100]`` so it stacks above sibling cards. Edge handling: if the thumbnail is near the viewport's right edge the popover flips to the LEFT side of the thumbnail; vertical position is clamped so the popover never overflows the window top or bottom. The thumbnail's own ``onClick`` is ``stopPropagation``'d so hovering the popover area never accidentally triggers the parent card's "open project" navigation. Tests: 2 new ``ProjectsPage.test.tsx`` cases — mouseenter mounts the popover at document.body level (not nested in the card subtree, which would re-introduce the clipping bug, and the assertion catches that); mouseleave unmounts it; the popover img points at the same cover-image URL as the small thumbnail with ``object-contain``; cards without a cover_image_filename never mount the portal-rendering component. |
||
|
|
86b54026f6 |
fix(inventory): hydrate Extra Colours; render Dual Color as bars; punch up Sparkle + checkerboard (#1154)
@maugsburger surfaced four bugs against the original #1154 multi-colour swatch work: 1. Editing an existing spool always opened with the Extra Colours field blank, even when the COLOR preview banner above it was rendering correctly from the saved data. ColorSection seeded its local ``extraColorsDraft`` via ``useState(formData.extra_colors)`` at mount time, but SpoolFormModal opens *before* its own useEffect populates ``formData`` from the spool record — so by the time the saved value landed, the input had already locked onto ''. The user then had to retype the value before saving anything else. 2. Dual Color and Gradient produced the same diagonal blend (``linear-gradient(135deg, A, B)``), so the two variants were visually indistinguishable. The whole point of the Dual Color variant is that the spool has two distinct bars on the reel — a smooth blend defeats it. 3. Sparkle was almost invisible on card-sized swatches. The original 4-dot pattern (each ~1px) read fine on the inline 20×20 swatch but disappeared on the 60-pixel inventory card banners — exactly where the user actually identifies a spool. 4. Checkerboard cell density scaled with the swatch — the same 4-cell pattern was either tiny squares on a small swatch or four huge squares on a card-sized banner. The user couldn't tell a translucent filament from a multi-colour one because the indicator changed shape. Fix: - ``ColorSection.tsx``: ref-guarded ``useEffect`` resyncs the draft whenever the parent's ``formData.extra_colors`` changes via an external update. ``commitExtraColors`` updates the ref before calling ``updateField`` so live user typing is round-tripped without the resync useEffect clobbering it. - ``filamentSwatchHelpers.ts: buildColorLayer``: branch on ``effect_type``. ``dual-color`` and ``tri-color`` produce ``linear-gradient(to right, c1 0 X%, c2 X% Y%, ...)`` with CSS double-position stops (hard line, not blend) and equal-width segments. ``gradient`` keeps the original 135° smooth blend. The ``multicolor`` conic-gradient path is untouched. - ``filamentSwatchHelpers.ts: EFFECT_OVERLAYS.sparkle``: bumped from 4 dots to 13 flecks in mixed sizes (1 / 1.5 / 2 px) and varying opacity (0.65 → 1.0) for a depth-of-field "metal flake" feel. - ``filamentSwatchHelpers.ts: buildFilamentBackground``: now returns ``{ backgroundImage, backgroundSize }`` so per-layer sizes can be applied — painted layers stay ``cover``, the checkerboard gets a fixed 12px tile so cell density is constant regardless of element size. Updated the three existing call sites (``InventoryPage`` group banner + spool card, ``ColorSection`` preview) to spread the style object directly. ``FilamentSwatch.tsx`` composes the same per-layer sizing inline so its output stays in lockstep. Tests: 8 new frontend cases pinning the four fixes — Dual/Tri Color hard-split (3 tests + 1 regression guard that Dual ≠ Gradient for the same stops), Sparkle prominence (≥ 10 distinct radial-gradient layers in the rendered background), checkerboard density (last backgroundSize layer is a fixed pixel value, not ``cover``), 4 hydration cases (fills when formData arrives via parent update, resyncs when the spool changes mid-form, doesn't clobber live user typing, clears when the new spool has no extra_colors). Existing buildFilamentBackground tests updated for the new return-object shape. Full frontend suite: 1610 passed; full backend suite: 3598 passed; no regressions. |
||
|
|
6a1e3454db |
fix(inventory): hydrate Extra Colours on edit + render Dual Color as hard-split bars (#1154)
@maugsburger surfaced two bugs against the original #1154 multi-colour swatch work: 1. Editing an existing spool always opened with the Extra Colours field blank, even when the COLOR preview banner above it was rendering correctly from the saved data. ColorSection seeded its local ``extraColorsDraft`` via ``useState(formData.extra_colors)`` at mount time, but SpoolFormModal opens *before* its own useEffect populates ``formData`` from the spool record — so by the time the saved value landed, the input had already locked onto ''. The user then had to retype the value before saving anything else. 2. Dual Color and Gradient produced the same diagonal blend (``linear-gradient(135deg, A, B)``), so the two variants were visually indistinguishable. The whole point of the Dual Color variant is that the spool has two distinct bars on the reel — a smooth blend defeats it. Fix: - ``ColorSection.tsx``: ref-guarded ``useEffect`` resyncs the draft whenever the parent's ``formData.extra_colors`` changes via an external update (modal opening with a spool, or switching to a different spool mid-form). ``commitExtraColors`` updates the ref before calling ``updateField`` so the user's own typing is round- tripped without the resync useEffect clobbering it. - ``filamentSwatchHelpers.ts``: ``buildColorLayer`` now branches on ``effect_type``. ``dual-color`` and ``tri-color`` produce ``linear-gradient(to right, c1 0 X%, c2 X% Y%, ...)`` with CSS double-position stops — the colour change is a hard vertical line rather than a blend region — and equal-width segments across N stops. ``gradient`` keeps the original 135° smooth blend. The ``multicolor`` conic-gradient path is untouched. Tests: 4 new ``FilamentSwatch.test.tsx`` cases pinning the hard-split contract (Dual Color uses ``to right`` not ``135deg``; Tri Color renders 3 equal hard-split bars; ``gradient`` keeps the smooth diagonal; explicit regression guard that Dual Color and Gradient never produce the same CSS string for the same stops). 4 new ``ColorSectionExtraColorsHydration.test.tsx`` cases pinning the input hydration (fills when formData arrives via parent update, resyncs when the spool changes mid-form, doesn't clobber live user typing, clears when the new spool has no extra_colors). Full frontend suite: 1608 passed; full backend suite: 3598 passed; no regressions. The minor "Sparkle could be more prominent / checkerboard denser" feedback in the same comment is deferred to a separate cosmetic pass — the reporter flagged it as finetuning. |
||
|
|
01a7e6ee93 |
fix(archive,vp): strip .gcode.3mf properly + sync review/archive name (#1152)
@smandon retested the original #1152 fix on the latest daily and surfaced two distinct holes: 1. ``Path(name).stem`` only strips the *last* suffix, so Bambu Studio's default ``Plate_1.gcode.3mf`` exports landed in the archive UI as ``Plate_1.gcode`` — never the bare ``Plate_1`` the user expected. 2. The pending-uploads review card always showed the raw FTP filename, while the eventual ``PrintArchive.print_name`` resolved from the 3MF's embedded title (or, with the toggle on ``filename``, the stripped stem). Net effect: same upload showed two different names depending on which view you were looking at, with no way for the toggle to flip both views in lockstep. Three changes: - ``resolve_display_stem`` helper in ``services/archive.py`` strips ``.gcode.3mf`` / ``.3mf`` / ``.gcode`` (case-insensitive). Applied at the archive-creation site so ``Plate_1.gcode.3mf`` → ``Plate_1`` for every flow that produces a ``PrintArchive`` row. - ``PendingUpload.metadata_print_name`` (new nullable column) is populated at FTP-receive time by peeking at the 3MF's embedded title via the existing ``ThreeMFParser``. Read happens once per upload — the list endpoint then doesn't have to reopen each 3MF on every render. Parser failures are swallowed and the column stays NULL; the response model gracefully falls back to the stripped filename. - ``PendingUploadResponse.display_name`` is a computed field that mirrors ``archive_print``'s exact precedence — ``filename`` toggle → stripped stem; ``metadata`` toggle (default) → cached title or stripped stem. The frontend's review card reads it (with ``upload.filename`` as a defensive fallback) and surfaces the raw FTP filename via tooltip so users can still inspect what arrived. Migration is one idempotent ``ALTER TABLE pending_uploads ADD COLUMN metadata_print_name VARCHAR(255)`` (Postgres/SQLite-safe). Pre-migration rows have NULL and degrade to filename-stem behaviour without any operator action. Tests: 14 unit tests in ``test_archive_display_stem.py`` covering the canonical normalisation rules (Bambu Studio default name, mixed case, dots-in-the-middle, edge cases like ``.gcode.3mf``-only, full-path inputs); 6 integration tests in ``test_pending_upload_display_name.py`` pinning the response contract (default toggle uses metadata title when present, falls back to stripped stem when absent, ``filename`` toggle overrides metadata, ``filename`` toggle still strips the double suffix, ``GET /{id}`` exposes the same field, whitespace-only metadata behaves like absent); 3 frontend tests in ``PendingUploadsPanel.test.tsx`` pinning the review card's render path (resolved name shown, fallback to filename when display_name is empty, raw filename available via tooltip). Full backend suite: 3598 passed; frontend build clean; no regressions in any flow that previously processed ``.3mf`` / ``.gcode`` / non-3D filenames. |
||
|
|
46468c9602 | Updated .github/workflows/cleanup-ghcr.yml | ||
|
|
133ec72527 |
feat(api-keys): per-user ownership + opt-in cloud access scope (#1182)
Tim (@turulix) is building a fully automated headless slicing pipeline against Bambuddy's API and hit the wall flagged in #665: /cloud/* routes resolve cloud_token per-user from User.cloud_token, but the auth gate returned None for API-keyed requests, so the route fell back to the global Settings-table token, which only carries a value in auth-disabled deployments. Net effect on auth-enabled deployments: API keys reached the gate just fine, then /cloud/filaments always saw user=None and returned 401 / empty results — no path to read slicer presets or the filament catalogue that a CLI workflow needs. Make API keys carry an owner and route /cloud/* lookups through that owner; gate the new capability behind an explicit opt-in scope so existing automation doesn't gain cloud-read access on upgrade. - APIKey gains user_id (FK to users.id, ON DELETE CASCADE) and can_access_cloud (BOOLEAN DEFAULT 0). User-delete route also runs an explicit DELETE FROM api_keys WHERE user_id = ? since SQLite ships FK enforcement off — same pattern as the existing created_by_id cleanup blocks. - New cloud_caller dep on /cloud/* routes resolves to the JWT user OR the API-key owner stashed by a router-level gate. The auth gate itself continues to return None for API keys so #1182's surface stays bounded to /cloud/* — without that bound, any route that fences API keys via `if current_user is None: raise 403` (e.g. long-lived-token management) would silently start accepting them. - The /cloud/* router-level dep enforces three independent fences for API-keyed callers: user_id IS NOT NULL (legacy keys → 401 with recreate copy), can_access_cloud=True (otherwise 403), and owner has cloud_token (existing fence, unchanged). Two extra one-shot fence errors at create/update time refuse can_access_cloud=True when auth is disabled or the key is ownerless. - Frontend: APIKey list shows "Cloud" badge on cloud-enabled keys and "Legacy" badge on ownerless rows; create form gains an "Allow cloud access" toggle, default off. New i18n keys in all 8 locales (en + de fully translated, others seeded with English fallbacks pending native translation — matches the project's flow for newly-added features). Migration: two idempotent ALTER TABLE statements + an index on user_id for the auth gate's owner→keys lookup. Postgres-safe. Tests: 9 backend integration tests in test_api_key_cloud_access.py covering creation flags, the three /cloud/* fences, JWT no-op, and deletion CASCADE; 2 frontend SettingsPage tests pinning the badge matrix and the create-form contract; 5 daemon unit tests for the related SpoolBuddy ssh-key sync work that landed in the same branch. Full backend suite: 3578 passed; full frontend suite: 1597 passed; no regressions. Permission semantics for existing keys: keys created before this release become "legacy" and are rejected at /cloud/* with the recreate message. Every other endpoint they were used against — queue, status, control — is untouched. |
||
|
|
2aabbe5d37 |
fix(spoolbuddy): sync SSH key over heartbeat to survive Bambuddy keypair rotation
Bambuddy's SSH keypair under <DATA_DIR>/spoolbuddy/ssh/ regenerates whenever
the data dir is recreated (volume remount, container recreate, fresh deploy).
The daemon previously only fetched the pubkey at registration, so any
rotation after a successful boot left ~/.ssh/authorized_keys pointing at
a stale public half — every Update click then failed with "Connection
closed by authenticating user spoolbuddy [preauth]" until the daemon was
restarted by hand. Each prior registration also appended a fresh entry
without pruning, accumulating stale Bambuddy-tagged keys indefinitely.
- HeartbeatResponse now carries ssh_public_key; the heartbeat route reads
it via the same try/except shape as the register route so a missing or
unreadable backend key doesn't break telemetry.
- _deploy_ssh_key() strips lines tagged bambuddy-spoolbuddy and writes
the current key once. No-op when already in sync (no mtime churn on
every heartbeat). User-managed entries are preserved.
- Daemon heartbeat handler calls _deploy_ssh_key when the response
carries a key, so rotations propagate within one heartbeat instead
of requiring a service restart.
Tests: 5 unit (creates-when-missing, replace-stale-pileup, preserve-user-keys,
idempotent, swallows-write-errors) + 2 backend integration (heartbeat carries
the key; backend key-read failure leaves ssh_public_key None but the
heartbeat still 200s).
|
||
|
|
44e4b6b5d7 |
Workflow: .github/workflows/cleanup-ghcr.yml runs Sundays at 03:00 UTC across both packages, with a manual workflow_dispatch and a dry_run toggle. It builds the live-digest
set the same way we just did, so it won't ever delete a digest still referenced by a tagged manifest. |
||
|
|
ddf3dc0c84 |
fix(camera): skip MJPEG warm-up frame, return second representative frame (#1177)
_capture_mjpeg_frame returned the very first JPEG it found in the
bytes stream, but many MJPEG sources — go2rtc most notably, and
several IP cameras — emit a warm-up frame on the byte that follows
connection accept: usually the last keyframe held in the encoder,
typically black or stale until the encoder catches up to live
content. Subsequent frames on the same connection are fine.
Result: every code path that opened a fresh capture (snapshot UX,
finish photos in notifications, timelapse, plate-detection CV,
Obico ML inference, Settings → Test button) returned a black image
on go2rtc-fronted cameras.
Reporter's support log showed every black frame was 11095 bytes
(pure-black 1280x720 JPEG ≈ 10-15 KB) while real-content frames
from the same source were 30-45 KB.
Fix:
- Read past the first complete JPEG, return the second.
- Fall back to the first frame if the connection closes / times out /
hits the 5 MB buffer cap before a second arrives. Without that
fallback, slow / single-frame streams that pre-fix returned the
warm-up would post-fix return None — a regression. The fallback
guarantees we never do worse than current behaviour.
- Inner while-loop now drains every complete frame already in the
buffer before pulling the next chunk so high-FPS sources that
pack multiple frames per chunk are handled correctly.
Untouched: snapshot / rtsp / usb capture paths, generate_mjpeg_stream
(live-view fan-out).
7 new regression tests in TestCaptureMjpegFrameWarmupSkip cover
two-frames-in-two-chunks, two-frames-in-one-chunk, partial-frame-
split-across-chunks, single-frame fallback, timeout fallback, zero-
frame stream returns None, non-200 returns None.
Latency penalty: at most one frame interval (typically 50 ms - 1 s
on a steady stream), well within every caller's tolerance window.
|
||
|
|
32b5c42dfb |
fix(permissions): hide MakerWorld nav entry from users without makerworld:view (#1175)
Backend routes were already gated on makerworld:view, the permission
was granted to admin + standard-user role defaults, and the frontend
Permission type union already included 'makerworld:view' — but the
sidebar's hand-maintained navPermissions map in Layout.tsx had no
entry for `makerworld`. So `isHidden('makerworld')` always returned
false, the entry rendered for every authenticated user regardless
of group permissions, and the only way the user found out they
couldn't use it was by clicking and getting 403'd by every API call.
Fix is two lines:
- Layout.tsx: add `makerworld: 'makerworld:view'` to navPermissions,
matching every other sidebar entry's gating shape.
- App.tsx: wrap the /makerworld route in PermissionRoute for defence
in depth, so a user who knows the URL can no longer reach the page
directly. Same pattern already used by settings, groups/new, and
groups/:id/edit two lines below.
Two new Layout tests pin the contract: with auth enabled and a user
lacking makerworld:view, the sidebar <a href="/makerworld"> link is
absent while other links still render; with the permission granted,
the link renders.
|
||
|
|
59c58362ba |
fix(printers): copy buttons in Printer Info modal work on plain HTTP (#1174)
PrinterInfoModal's CopyButton only tried navigator.clipboard.writeText(),
which is gated by the secure-context requirement (HTTPS or localhost).
On the typical Bambuddy deployment shape — bare-IP HTTP on the LAN —
navigator.clipboard is undefined; the existing try/catch swallowed the
TypeError, the icon never flipped to the tick, and nothing landed on
the user's clipboard.
Fixed by adding the same off-screen-textarea + document.execCommand('copy')
fallback that CameraTokensPage's plaintext-token modal already uses for
plain-HTTP LAN deployments. Gate on `navigator.clipboard && window.isSecureContext`,
fall back to the legacy path otherwise, and surface the success-tick only
when the copy actually landed (return early without flipping `copied` if
execCommand returns false). The try/finally around the textarea guarantees
DOM cleanup even when the browser throws on a restricted context.
3 new component tests in PrinterInfoModal.test.tsx cover the secure-context
happy path (navigator.clipboard.writeText is called with the correct value),
the plain-HTTP fallback path (execCommand is invoked, no leaked textarea
left in the DOM), and the finally cleanup when execCommand throws
synthetically.
|
||
|
|
25eab96817 |
fix(scheduler): raise plate-clear gate for every terminal status (#1171)
The plate-clear gate added in #961 was raised only when a print ended with status completed or failed. Aborted prints (printer self-abort or a user stopping the print from the printer's own touchscreen) and cancelled prints (user stopping via the Bambuddy queue UI) did NOT raise the flag, so the queue scheduler dispatched the next pending item ~2 seconds later onto a fouled bed. The reporter saw two prints (P1P + P1S) auto-start onto fouled beds within seconds of touchscreen-aborts, and explicitly flagged the risk of damage to the printer. A third printer behaved correctly because its previous print had ended "completed" — the asymmetry he noticed was the gate working for one terminal status and not the other three. Touchscreen-aborts are particularly important to gate. Bambuddy's existing "user stopped via UI" override (which translates aborted to cancelled when _user_stopped_printers is populated) only fires for stops through the Bambuddy queue UI; a touchscreen stop reports aborted straight through. The original code comment claimed user-cancelled prints don't need a plate-clear ack because "nothing printed on the bed". That only holds if you cancel right at layer 1; a cancel at hour 11 of a 12-hour print leaves a fully fouled bed. The gate is user-clearable on the Printers page, so worst case a user who cancels at layer 1 clicks "Clear Plate" once — that's a non-issue compared to auto-dispatching onto material. Regression coverage in test_print_lifecycle.py::TestPlateClearGate: parametrised across all 4 terminal statuses asserting set_awaiting_plate_clear(printer_id, True) is called for each, plus a defence-in-depth test that an unrecognised future status string never silently raises the gate. |
||
|
|
4aea4be2bd |
feat(updates): detect HA Supervisor addon and defer update UI to it (#1167)
Bambuddy already supports running as a Home Assistant addon (HA_URL/HA_TOKEN env-var integration since #283, community addon at hobbypunk90/homeassistant-addon-bambuddy), but the update UI was oblivious to it: HA addon users saw the in-app "Update available" banner and, on Settings, the docker-compose snippet — neither of which they can act on, since the HA Supervisor owns the addon lifecycle. Detection uses the SUPERVISOR_TOKEN env var that HA Supervisor injects into every addon container; no other environment sets it, so the check has zero false-positive surface. Backend: - new _is_ha_addon() helper in routes/updates.py - /updates/check now returns is_ha_addon: bool and extends update_method to 'git' | 'docker' | 'ha_addon' - /updates/apply checks HA before Docker (HA addons ARE Docker containers, so checking docker first would mis-classify) and returns an HA-specific message that points to Settings → Add-ons → Bambuddy in HA - response keeps is_docker: true alongside is_ha_addon: true so older frontend bundles still hit a managed-deployment branch instead of rendering an Install button that can't work Frontend: - SettingsPage update card branches on is_ha_addon BEFORE is_docker; HA users get a Supervisor-targeted message instead of the docker-compose snippet - Layout update banner is suppressed for HA addons — HA Supervisor surfaces its own update notification natively, so Bambuddy's banner would be duplicate noise linking to a page that just says "update via HA" - Plain Docker deployments are unaffected i18n: settings.updateViaHomeAssistant added to all 8 locales with full native translations. Tests: 3 backend unit tests for _is_ha_addon (present, absent, empty-string treated as unset), 3 backend integration tests (HA-precedes-Docker rejection on apply; HA branch on check; plain Docker branch on check), 2 SettingsPage tests pinning the mutually-exclusive UI rendering, 2 Layout tests pinning banner suppression for HA and retention for plain Docker. |
||
|
|
889c8bd87f |
fix(printers): show correct plate thumbnail on multi-plate 3MFs (#1166)
P1S 01.10.00.00 (and similar firmware revisions) only echo the .3mf
filename in print.gcode_file, dropping the Metadata/plate_N.gcode path.
The /cover route's regex falls back to plate 1 — and the printer card
shows the wrong plate's thumbnail on multi-plate prints.
Resolution order in the new resolve_plate_id() helper (used by both
the status route's current_plate_id and /cover):
1. The plate Bambuddy dispatched. start_print() now records
(dispatched_plate_id, dispatched_subtask) on PrinterState; the
subtask check rejects stale records from a previous Bambuddy
dispatch bleeding into a Studio-direct print on the same project.
2. plate_(\d+)\.gcode regex on state.gcode_file (existing behaviour
for firmware that does include the path).
3. After download, scan the 3MF for a unique Metadata/plate_*.gcode —
covers per-plate archives sliced separately in Studio without a
Bambuddy dispatch record.
4. Default to plate 1.
Cover-byte cache key simplified to (subtask_name, view_key) now that
plate resolution is late-bound. clear_cover_cache() already fires on
every print start, so re-dispatches with a different plate always
fetch a fresh thumbnail.
Bambuddy-dispatched prints additionally register the local archive
3MF in the cover cache at dispatch time, so /cover reads straight
from the archive directory and doesn't refetch the file over FTP
from a printer whose FTP server is busy serving the active print.
Coverage: 5 unit tests for resolve_plate_id, 4 unit tests for the
dispatch record on start_print, 2 integration tests for the cover
route (dispatch wins over plate-1 default; 3MF-scan fallback for
per-plate archive without dispatch record).
|
||
|
|
b45ca2a662 |
feat(printer): support Filament Track Switch (FTS) accessory in print modal (#1162)
The FTS routes any AMS slot to either extruder, so AMS info reports
bits 8-11 = 0xE (uninitialized) and ams_extruder_map ends up empty.
The print modal's per-nozzle dropdown filter then hides every loaded
slot, leaving the user with an empty filament dropdown.
Detection: parse print.device.fila_switch from MQTT push_status into a
new FilaSwitchState dataclass on PrinterState; surface it through the
GET /printers/{id}/status response as a nullable FilaSwitchResponse.
Frontend: useFilamentMapping and FilamentMapping skip the per-extruder
filter when fila_switch.installed is true. Slots currently fed into a
track display an [L]/[R] routing badge in the dropdown so the user
can see where the FTS is currently routing them.
Tests: 4 backend unit (TestFilamentTrackSwitchDetection), 2 backend
integration (status route), 2 hook regression, 2 component regression.
|
||
|
|
f45f6a131d | Updated Github issue template | ||
|
|
dac6cbfe4e |
fix(mqtt): reset unanswered counter on any ams_filament_setting response (#1164)
Configuring AMS slots ~6 times in a row would silently stop reaching the printer, with filament colours jumping around briefly ~1 min later. Root cause was the zombie-session watchdog from #887. When an ams_filament_setting response took >10 s (normal under load) the watchdog set `_ams_cmd_unanswered=1` and zeroed `_last_ams_cmd_time` so it wouldn't re-fire on every status push. The response handler that resets the counter required `_last_ams_cmd_time > 0` — so when the late response arrived, the reset path skipped it, leaving the counter armed at 1. The next slow response on a fresh command (possibly minutes or hours later) would take the counter to 2 and force-reconnect mid-publish — the in-flight command got dropped, surfacing as "Cannot set AMS filament setting: not connected" if the user retried during the ~1 min reconnect window. Fix: drop the `_last_ams_cmd_time > 0` guard. Any ams_filament_setting response proves the channel is alive, so the counter must reset unconditionally. Real zombie sessions (no responses at all for two consecutive >10 s windows) still trip the watchdog correctly. Regression test in test_bambu_mqtt.py drives the exact reporter sequence: watchdog fires (clears timer, increments counter) → late response arrives (must reset counter) → next slow response (must only count as 1, not 2). Other 10 zombie-detection tests still pass. |
||
|
|
68c4a5b839 |
fix(updates): install the discovered release tag, not hardcoded origin/main
The in-app updater ran `git fetch origin main && git reset --hard origin/main` regardless of which version the GitHub releases API reported as latest. So whenever the latest release lived on a branch other than main — e.g. during a beta cycle when 0.2.4b1 sits on its own branch and main still points at the previous stable — clicking Apply Update appeared to succeed but the user actually stayed pinned to old main HEAD. Fix: extract `_discover_target_release(db)` mirroring the same release-API + include_beta_updates selection the GUI's update-check already uses, pass the resolved tag (e.g. `v0.2.4b1`) into `_perform_update(target_ref)`, and run `git fetch --prune --tags origin && git reset --hard <target_ref>`. The fetch now pulls --tags so a tag ref is locally resolvable; the reset takes the caller's ref instead of a hardcoded branch. apply_update now returns a clear error if no release resolves, instead of silently kicking off an update that can't land. |
||
|
|
cc5692a283 |
fix(updates): preserve SSH origin pointing at the right repo
The in-app Apply Update path unconditionally ran `git remote set-url origin https://github.com/maziggy/bambuddy.git` before fetching, on the theory that systemd service users wouldn't have SSH keys. True in production, but it also clobbered every developer's SSH origin the moment they tested the upgrade flow against their own checkout. Next `git push` then prompted for HTTPS credentials and bounced. New behaviour: read `origin` first via `git remote get-url`, parse out the (owner, repo) pair using a small helper that handles all four canonical forms (git@github.com:owner/repo[.git] and https://github.com/owner/repo[.git]), and only rewrite if it doesn't already resolve to maziggy/bambuddy. Native installs with no remote or pointing at a fork still get reset to the canonical HTTPS URL. Three new regression tests in test_updates_api.py: - parser accepts SSH/HTTPS, with/without .git, rejects non-GitHub - SSH origin pointing at maziggy/bambuddy is preserved (the developer-footgun case) - origin pointing at a fork still gets rewritten to HTTPS (the original behaviour we don't want to lose) |
||
|
|
f6dc3a2661 | Updated CHANGELOG | ||
|
|
a85855f2dd |
fix(updates): run pip install in app_dir, not base_dir, on native installs
Native-install upgrade via the in-app Apply Update button got the new
code in via `git reset --hard origin/main` but then logged
ERROR: Could not open requirements file:
[Errno 2] No such file or directory: 'requirements.txt'
and continued. The new deps never installed, leaving the user with
new code but stale dependencies — surfaces as cryptic import errors
on the next restart.
Root cause: `pip install -r requirements.txt` ran with
`cwd=settings.base_dir`. On a native install, systemd sets
DATA_DIR=$INSTALL_PATH/data so base_dir resolves to the data dir
(e.g. /opt/bambuddy/data), not the source tree. Pip doesn't walk up
looking for the requirements file the way git walks up looking for
.git, so it fails. Same bug affected the optional npm step
(`frontend_dir = base_dir / "frontend"` doesn't exist).
Fix: introduce `settings.app_dir` pointing at the source-tree root
(distinct from `base_dir` only on native installs) and run pip +
npm with `cwd=settings.app_dir`. Git ops keep using `base_dir`
because they already work (git walks up).
Docker users were unaffected — Docker doesn't use the in-app updater
(image pull replaces it).
Regression test in test_updates_api.py mocks every subprocess in
_perform_update, captures their cwd, and asserts the pip step runs
in app_dir and that requirements.txt actually exists there. Any
future refactor that re-introduces cwd=base_dir for the pip step
fails CI before another user trips over it.
|
||
|
|
eec9ef4a41 | Bumped version | ||
|
|
be6342932f |
fix(restore): drop tables with CASCADE so orphan FKs can't abort restore
Settings -> Backup -> Restore on a Postgres-backed Bambuddy aborted with `cannot drop table printers because other objects depend on it` when the live DB held orphan tables from removed features. Legacy `spoolman_slot_assignments` / `spoolman_k_profile` from an earlier Spoolman integration still sat in the schema with `*_printer_id_fkey` constraints back to `printers`, so `metadata.drop_all` (which only knows about ORM tables, no CASCADE) couldn't drop `printers` and the whole restore aborted before any rows landed. Replace `metadata.drop_all` with a `pg_tables`-iterating PL/pgSQL DO block that DROPs every public-schema table with CASCADE, then call `metadata.create_all` to rebuild the schema. CASCADE removes external constraints alongside the table, and a "restore" is intentionally destructive — the user has explicitly chosen to wipe the DB and replace from backup. Two regression tests in test_postgres_restore_drop_cascade.py mock the Postgres engine, capture the SQL stream, and assert (a) the CASCADE+pg_tables iteration is emitted and metadata.drop_all is never called, (b) the drop is scoped to public schema so shared Postgres setups aren't taken out. SQLite restores go through a separate path and are unaffected. |
||
|
|
7bc4712480 |
chore(i18n): full parity across all 8 locales + drop two-tier check
Backfill 276 missing translations across fr / it / ja / pt-BR so all
8 shipped locales now match en. Most gaps come from features that
landed with en+de+zh translations only:
- login.resetPassword.* (12 × 3): fr, it, ja
- printers.firmwareModal.* (7 × 4): all locales
- settings.spoolbuddy.* (~40 × 3, plus 18 in ja): admin device-
control block (unregister / reboot / shutdown / update /
restart confirms)
- spoolbuddy.settings.* (13 × 4): kiosk backend & auth + diagnostics
- virtualPrinter.archiveNameSource.* (4 × 4): from this release
Also fixes 27 ja and 1 fr placeholder-name mismatches that silently
broke interpolation at runtime — e.g. printers.activeNozzle used
{{side}} while the runtime passes nozzle, and several keys had
{{count}} dropped entirely so the value would never render.
Drop the STRICT / info two-tier machinery from check-i18n-parity.mjs:
en is the reference, every other locale is checked identically,
any drift fails CI. The previous tier was just deferred policy, no
real distinction.
All 8 locales now sit at 4492 leaves. Parity script, i18n test suite
(11 tests), and full frontend build all green.
|
||
|
|
a34beaa599 |
feat(inventory): multi-colour gradients, transparency, visual effects (#1154)
Spool and color_catalog rows carry extra_colors (comma-separated hex stops) and effect_type (14 visual variants: surface effects, sheen, structural). The shared FilamentSwatch component renders gradient, conic, effect overlay, and alpha-checkerboard consistently across the inventory grid, table, group banner, card, ColorSection preview, and catalog editor. Catalog hex_color accepts #RRGGBBAA so catalog entries can carry transparency too. The paste field accepts the exact format 3dfilamentprofiles.com puts on its filament details pages, so users can copy a multi-colour combo directly. The effect dropdown spans the full filament-variant vocabulary -- surface effects (sparkle/wood/marble/glow/matte), sheen variants (silk/galaxy/rainbow/metal/translucent), and structural variants (gradient/dual-color/tri-color/multicolor). None of these fields touch MQTT/firmware -- pure visual hint. Spool group-key extended to include extra_colors + effect_type so "Group similar" no longer collapses visually distinct spools. Migrations: 4 idempotent ALTER TABLE ADD COLUMN (Postgres-safe), plus ALTER COLUMN hex_color TYPE VARCHAR(9) on Postgres only (SQLite ignores VARCHAR length). Tests: 42 new backend (35 unit + 7 integration), 20 new frontend (14 FilamentSwatch + 3 ColorCatalogSettings + 3 InventoryPageGrouping regression). 3522 backend + 1582 frontend tests pass; ruff clean. Localised across all 8 UI locales. |
||
|
|
57af8a1c19 |
feat(projects): URL field + cover photo on project cards (#1155)
Two new project fields: a free-text URL rendered as a one-click
external-link button beside the project name on every card (opens in a
new tab, click is e.stopPropagation()-guarded so it doesn't enter the
project), and a cover photo that replaces the status-icon box with a
square thumbnail.
URL is plumbed through ProjectCreate/Update/Response/ListResponse,
including from-template + create-template flows so it inherits between
a project and its template. Cover photo is not inherited because the
file would be shared on disk between source and copy.
Schema validator rejects anything other than http:// or https://
prefixes -- <a href> rendering would otherwise execute javascript:
/ data: / file: URLs even with React's default escaping. PATCH uses
model_fields_set for the URL field so users can clear it by sending
{"url": null}.
Cover image storage: Project.cover_image_filename references a file
Cover image storage: Project.cover_image_filename references a file
inside the existing archives/projects/{id}/attachments/ dir, but it's
tracked separately from the attachments JSON list so swap/delete on
the cover doesn't perturb the user's other attachments. Three routes
(POST/GET/DELETE /projects/{id}/cover-image) accept only .jpg/.jpeg/
.png/.gif/.webp (no SVG -- SVG can carry script payloads), replace in
place (prior file deleted before the new one lands so repeat uploads
can't accumulate orphans), and self-heal when a DB reference points at
a vanished disk file by clearing the column and 404'ing.
GET cover-image is gated by RequireCameraStreamTokenIfAuthEnabled
(accepts ?token=... query string) -- not the bearer-token gate -- so
<img src> requests work in both auth-on and auth-off configurations.
The frontend wraps getProjectCoverImageUrl with withStreamToken(),
matching the existing pattern from getArchiveThumbnail.
Permissions: PROJECTS_UPDATE for upload/delete/PATCH, PROJECTS_READ
gate is implicit via the stream-token credential. Migration: 2
idempotent ALTER TABLE projects ADD COLUMN. Localised across all 8
UI languages.
|
||
|
|
b6a9d56651 |
feat(archives): Not Printed / Printed collections (#1153)
VP-uploaded archives land with status='archived' (uploaded but never sent to a printer), but the Archives page sidebar only offered All/Recent/This Week/This Month/Favorites/Failed/Duplicates -- no way to filter "what's still queued in my library" vs "what's been printed." Two new collections fill the gap: Not Printed filters to status==='archived'; Printed filters to any final-status archive (completed/failed/aborted/cancelled/stopped) so users see every archive that had a print attempt regardless of outcome (the existing Failed collection covers just the failure subset). Frontend-only -- the status field was already populated correctly by the VP archive paths, this was purely a UI gap. 2 new tests pin the filter behaviour against a 4-status fixture. |
||
|
|
c2e7f8eb4b |
feat(vp): add archive name source toggle (metadata/filename) (#1152)
Slicer-uploaded archives picked up their display name from the 3MF's
embedded print_name (the creator-baked title); users who renamed a job
in BambuStudio's "Send to printer" dialog never saw that name surface
because the FTP filename was only used as a fallback when metadata was
empty.
Settings -> Virtual Printer now exposes an Archive name source toggle
(Metadata / Filename, default Metadata) that flips precedence in
ArchiveService.archive_print via a new prefer_filename_for_name param.
All four VP-sourced archive paths read the new
virtual_printer_archive_name_source setting and forward the flag:
_archive_file, _add_to_print_queue, POST /pending-uploads/archive-all,
POST /pending-uploads/{id}/archive.
|
||
|
|
418f5168f5 | Updated docker-publish-daily-beta.sh | ||
|
|
c0c9e19e53 |
docs(changelog): consolidate duplicate Fixed/Added sections in 0.2.4b1
The unreleased section accumulated two ### Fixed and two ### Added headers from successive PRs each adding their own subsection instead of appending under the existing one. Merge into one section per type, in canonical Keep-a-Changelog order (Added / Changed / Fixed / Security). All 68 entries preserved verbatim — no content change. |
||
|
|
724bc92c22 |
fix(scheduler): post-dispatch hold prevents H2D Pro double-fire (#1157)
Multi-plate batches scheduled to the same H2D Pro were triple-dispatched
within ~60 s — observed in user logs as queue items 139/140/141 all
flipping to status='printing' even though the printer was still
digesting the first project_file (FINISH for 80-210 s before flipping
to PREPARE). The DB busy_printers seed at print_scheduler.py:145 was
empirically missing the in-flight items in this window; without
database access I cannot pin the exact why, but the guard is unreliable.
Add a defensive in-memory dispatch hold:
- _start_print captures (dispatched_at, pre_state, pre_subtask_id) per
printer
- check_queue augments busy_printers with any printer still inside its
hold window (60 s minimum cooldown, 180 s hard timeout)
- _watchdog_print_start releases the hold once it observes a state or
subtask_id transition (success path), or on the existing 90 s revert
(unhappy path), or on disconnect
Pure additive — alongside the existing seed query and _is_printer_idle.
Doesn't depend on DB row visibility or on_print_complete firing
correctly. Per-printer isolated. Watchdog kept as @staticmethod so the
existing 12 watchdog tests pass unchanged; hold-release calls go
through the module-level scheduler instance.
|
||
|
|
59b714e857 |
fix(archives): unbreak project-picker scroll, sort + search (#1151)
- ContextMenu's capture-phase document.scroll listener was firing on
internal submenu scrolls too, slamming the whole menu shut on any
wheel / arrow-key / scrollbar interaction past the 300px max-height.
Handler now ignores scrolls whose target is inside menuRef so only
page-level scrolls dismiss.
- Sort projects alphabetically (localeCompare) at every project-picker
site: Archives context-menu submenu (x2), BatchProjectModal,
EditArchiveModal, PendingUploadsPanel, FileManagerPage. Native
<select> sites sort once via react-query's select option; custom
button lists sort inline.
- Add filter-by-name search to the Archives "Add to Project" submenus
(new submenuSearchPlaceholder prop on ContextMenuItem) and to
BatchProjectModal. Both gated on >5 projects so small libraries
stay uncluttered. Enter picks the first match.
- New archives.menu.searchProjects i18n key in all 8 locales (en/de
translated; six others seeded with English copies pending native
translation, matching the existing flow).
|
||
|
|
d5153f1de3 |
feat(slicer): live progress + filament discovery polish + OrcaSlicer warning
End-to-end live progress, two correctness fixes, and a UX warning around
the upstream OrcaSlicer bugs we discovered while testing.
LIVE PROGRESS
=============
Wire OrcaSlicer / BambuStudio's --pipe progress channel through the
sidecar -> Bambuddy -> persistent toast so a user-initiated slice shows
"{name} -- Generating G-code (75%) -- 47s" instead of just elapsed time.
The same wiring covers the SliceModal's filament-analysis preview slice
(the real slice that fires before profile picking, used to discover
which AMS slots an unsliced plate consumes) and the embedded-settings
fallback path triggered by Orca's --load-settings segfault on complex
H2D models.
- Sidecar (orca-slicer-api/bambuddy/profile-resolver, separate commit):
switch /slice from execFile to spawn, mkfifo per request, parse the
CLI's structured JSON progress events into a per-process
ProgressStore, expose GET /slice/progress/:requestId.
- Bambuddy backend: slicer_api.slice_with_profiles + slice_without_profiles
accept request_id + on_progress, spawn a 1Hz parallel poller that
forwards each snapshot via SliceDispatchService.set_progress(job_id,
...) onto the matching SliceJob; GET /slice-jobs/:id includes the
latest snapshot on every poll. The 404 from the early-race window
(POST fired before sidecar's progressStore.start) is treated as a
retry rather than terminal -- otherwise the poller bailed before any
progress could ever arrive.
- /api/v1/slicer/preview-progress/:requestId proxies the sidecar's
progress endpoint for the modal's filament-discovery flow (the
/filament-requirements call is server-originated; the browser can't
reach the sidecar directly).
- Frontend: SliceJobTrackerContext re-renders the persistent toast with
the new format when a useful progress frame is present, falls back
to elapsed-time-only when the sidecar hasn't emitted yet or doesn't
support progress. SliceModal.FilamentAnalysisSpinner generates a
per-(source, plate) UUID, polls the proxy at 1Hz, and mirrors the
inline spinner contents into a separate persistent toast so the
preview slice doesn't feel silent either.
CORRECTNESS FIXES
=================
- MakerWorld imports were persisting URL-encoded filenames verbatim
("stormtrooper-helmet%20h2d.3mf"). Backend now urllib.parse.unquote
s the manifest-supplied name and the URL path-tail fallback before
passing to save_3mf_bytes_to_library; frontend defensively
decodeURIComponent s in the slice toast / analysis spinner so
already-imported rows display cleanly without a backfill migration.
- The fallback path's slice_without_profiles call now forwards the
same request_id + on_progress as the primary slice_with_profiles
call so the toast keeps updating across the segfault -> embedded-
settings retry boundary instead of going blank.
ORCASLICER WARNING
==================
Verified two upstream OrcaSlicer CLI bugs reproduce on the latest
nightly (2.4.0-dev, 2026-04-28) with the help of an isolated AppImage
extract and a minimal sentinel-value-injected cube fixture:
- OrcaSlicer/OrcaSlicer#12426 -- SIGSEGV in
update_values_to_printer_extruders_for_multiple_filaments on
painted multi-extruder 3MFs (commented on the existing thread,
not a new issue)
- OrcaSlicer/OrcaSlicer#13386 -- CLI strict-validates parameter
values BambuStudio writes by default (solid_infill_filament: 0,
tree_support_wall_count: -1, prime_tower_brim_width: -1) and
rejects with exit 238, even though Orca's own GUI tolerates
them (filed by us alongside this change)
Settings -> Workflow -> Slicer card renders an amber inline warning
under the preferred-slicer dropdown when orcaslicer is selected,
linking both upstream issues and recommending BambuStudio until the
fixes land. Option stays pickable -- users who only slice STLs aren't
affected by either bug.
|
||
|
|
3fa0b621dd |
fix(slicer): correct multi-color slice output + UX polish across the slice pipeline
The slicer CLI silently substitutes embedded defaults for any AMS slot
the user didn't supply a profile for. When a multi-color project (e.g.
a MakerWorld helmet with white shell + grey support filament configured
project-wide) was sliced for a "single-color" plate, the CLI took the
user's white pick for slot 1 and quietly filled slot 2 with the source
3MF's embedded grey support filament — producing a slice the user never
asked for. Same silent-fallback class as the strip-removal bug.
Backend `/filament-requirements` now returns the FULL project AMS slot
list (from project_settings.config) with a `used_in_plate: bool` flag
per entry. The flag comes from the cached preview slice for unsliced
files; sliced files (where slice_info.config already pre-filters by
used_g > 0) get used_in_plate=true on every entry. SliceModal renders
one dropdown per project slot — slots flagged used_in_plate=true are
editable, slots flagged false are auto-picked from project metadata
via the existing colour-match scoring and disabled with a
"-- not used by this plate" suffix. The wire format always carries a
profile per project slot, so the CLI never falls back to embedded
defaults.
Adjacent UX fixes in the same session, since they all hit the same
flow:
- Persistent slice-progress toast: SliceJobTrackerProvider now opens
a persistent loading toast per active job ("Slicing X -- 47s") with
a 1Hz elapsed-time tick and replaces it with the existing transient
success/error toast on terminal state. The previous start+finish
toast pair left a UX dead zone where users couldn't tell whether a
long slice was still running.
- "Analyzing plate filaments..." spinner now shows elapsed seconds and,
after 5s, a hint that the wait is a one-time preview slice (cached;
re-opens are instant). Addresses "is anything happening?" on first
open of an unsliced complex multi-color 3MF.
- Pre-slice printer-mismatch warning + disabled Slice button: the
plates response now exposes `source_printer_model` from
project_settings.config; SliceModal compares against the picked
printer profile name and surfaces an inline warning + disables Slice
on mismatch. The CLI rejects cross-printer slices (rc=-16) and used
to fall back to embedded settings, producing wrong-printer g-code
that errored at print dispatch.
- Sliced-archive card now reflects the actually-used filament list,
not the source's project-wide AMS config:
slice_and_persist_as_archive reads filament_type / filament_color
from the sliced output's slice_info.config (which already gates on
used_g > 0) instead of inheriting from the source archive. A 16+
swatch card on what was actually a 2-color print was the visible
symptom.
- MakerWorld URL-paste resolver enriches each instance with
`compatibility` + `otherCompatibility` from
design.instances[].extention.modelInfo. The /instances/hits payload
omits this so every instance row used to look identical; users
blindly picked the first one regardless of whether it matched their
printer.
Tests: 27 SliceModal tests (2 new for the disabled-row contract +
3 for printer-mismatch + 4 for multi-color rendering); 4 new
SliceJobTrackerContext tests for the persistent-toast lifecycle;
backend filament-requirements / slice-preview / threemf-tools
suites green.
i18n: new keys slice.queuedToast, slice.runningToast,
slice.analyzingPlateFilamentsHint, slice.notUsedByPlate,
slice.printerMismatch, makerworld.slicedFor, makerworld.alsoCompatible
across all 8 UI languages (English + German fully translated, the six
others seeded with English copies pending native translation, matching
the project's existing flow for newly-added user-facing features).
|
||
|
|
c1f69ee0cc |
fix(slicer): wrong-printer slicing + sliced-archive filament list + per-instance MakerWorld compat
Five stacked slice-pipeline bugs that each made the modal's profile picker
theatrical for 3MF inputs:
(1) `_strip_3mf_embedded_settings` removed `model_settings.config` /
`slice_info.config` / `cut_information.xml` along with
`project_settings.config`. The CLI silently exited after
"Initializing StaticPrintConfigs" — exit 0, no result.json — and
Bambuddy masked the failure by re-running with embedded settings
and the source's bound printer. Strip removed from the dispatch
path entirely.
(2) Standard-tier preset stubs lacked the `type` field, so the CLI
rejected `--load-settings` with rc=-5 ("input preset file is
invalid") and the same masking fallback fired. Added
`_SLOT_TO_PROFILE_TYPE` so each stub carries the right
machine/process/filament discriminator.
(3) Sliced-archive cards listed every project-wide AMS slot (16+
swatches for a 2-color print). `slice_and_persist_as_archive` now
reads `filament_type` / `filament_color` from the sliced output's
`slice_info.config` (which `ThreeMFParser` already gates on
`used_g > 0`) instead of inheriting from the source archive.
(4) SliceModal had no warning when the picked printer profile didn't
match the source 3MF — the CLI rejects cross-printer slices
(rc=-16) and fell back to embedded settings, producing wrong-printer
g-code that errored at print dispatch. Plates response now exposes
`source_printer_model`; the modal compares against the picked
profile name and disables Slice + shows an inline warning on
mismatch.
(5) MakerWorld URL-paste resolver listed plate instances without
showing which printer each was sliced for (`/instances/hits`
omits compatibility info that lives on `design.instances[]
.extention.modelInfo`). The resolve route now joins both payloads
by instance ID and forwards `compatibility` + `otherCompatibility`
onto each hit; the MakerWorld page renders "Sliced for {primary}"
+ "Also marked compatible: ..." per row.
Tests: 6 unit tests for `extract_source_printer_model_from_3mf`, 1 for
filtered filament metadata via ThreeMFParser, 2 for makerworld resolve
compat-merge (happy path + missing modelInfo), 3 frontend SliceModal
tests for the printer-mismatch warning + Slice-disabled gate. New i18n
keys `slice.printerMismatch`, `makerworld.slicedFor`,
`makerworld.alsoCompatible` across all 8 locales.
|
||
|
|
6497eefe60 |
● feat(slicer): multi-color slicing + per-plate filament discovery
The slice modal previously rendered exactly one filament dropdown and
silently truncated multi-color 3MFs to a single profile, producing wrong
colours on every multi-filament print. End-to-end fix across sidecar,
backend, and frontend.
Sidecar (orca-slicer-api / bambuddy/profile-resolver, separate commit):
- /slice accepts up to 16 repeated filamentProfile parts; slicing
service materializes each and joins paths with `;` for
--load-filaments.
- /profiles/bundled emits filament_type and filament_colour per leaf
so the bundled tier carries metadata into the modal.
Bambuddy backend:
- SliceRequest gains filament_presets: list[PresetRef]. Validator
accepts three shapes (multi-color array, source-aware singular,
legacy bare-int id) and lands them all on a populated array before
the route handler runs — fully backwards-compatible.
- SlicerApiService.slice_with_profiles takes filament_profile_jsons:
list[str] and sends one filamentProfile multipart part per profile
(in submission order) so the sidecar receives N profiles cleanly.
- New service slice_preview runs the sidecar's slice_without_profiles
against an unsliced project file's embedded settings, parses the
result's slice_info.config, and returns the canonical per-plate
filament list. Cached by (kind, source_id, plate_id, content_hash)
with LRU eviction at 256 entries, per-key asyncio.Lock prevents
thundering-herd; transient sidecar failures are NOT cached so they
retry naturally; parse failures ARE cached (deterministic property
of the input, no point re-running).
- /filament-requirements endpoint chain: slice_info.config (existing,
sliced files) → preview-slice (new, unsliced project files) →
project_settings.config + painted-face heuristic with 5% noise
threshold (sidecar-down fallback).
- threemf_tools gains extract_project_filaments_from_3mf and
extract_plate_extruder_set_from_3mf — the latter unions object
top-level extruder, per-part overrides, and painted-face quadtree
leaves (1-E nibbles in paint_color attrs of <triangle> elements
inside per-object .model files).
- Cloud preset listing no longer fetches per-preset detail (Bambu's
rate limit at ~10/sec returns 429 on every request for users with
50+ presets). Unified-listing dedup pass instead backfills metadata
cross-tier so a cloud entry that wins dedup over a same-named local
entry inherits the local's filament_type / filament_colour.
- slice_and_persist_as_archive now reads filament_type / filament_color
from the SLICED OUTPUT's slice_info.config (via ThreeMFParser, which
already gates on used_g > 0) instead of inheriting from the unsliced
source archive. Without this, archive cards for sliced multi-color
prints showed every project-wide AMS slot — 18 swatches for a
2-color print — instead of just the filaments actually consumed.
Frontend:
- SliceModal multi-step: plate-picker first when the source is a
multi-plate 3MF, then preset dropdowns. One filament dropdown per
AMS slot the plate actually uses, each pre-picked by metadata
match against user's local + standard presets via existing
colorsAreSimilar / normalizeColorForCompare utils.
- SliceModal-only tier priority is now local → cloud → standard
(was cloud → local → standard). Other consumers of /slicer/presets
keep the existing cloud-first order.
- Submits filament_presets array; backfills the legacy singular
filament_preset from the array's first entry for stale-tab
compatibility.
- i18n keys added across all 8 locales: slice.filamentSlot,
slice.tier.{local,cloud,standard}, slice.cloud.{notAuthenticated,
expired,unreachable}, slice.noPresetsForSlot,
slice.allPresetsRequired (en + de fully translated; six others
seeded with English copies pending native translation, matching
the project's existing flow).
|
||
|
|
4acdcd7203 |
● feat(slicer): multi-color slicing + per-plate filament discovery
The slice modal previously rendered exactly one filament dropdown and
silently truncated multi-color 3MFs to a single profile, producing wrong
colours on every multi-filament print. End-to-end fix across sidecar,
backend, and frontend.
Sidecar (orca-slicer-api / bambuddy/profile-resolver, separate commit):
- /slice accepts up to 16 repeated filamentProfile parts; slicing
service materializes each and joins paths with `;` for
--load-filaments.
- /profiles/bundled emits filament_type and filament_colour per leaf
so the bundled tier carries metadata into the modal.
Bambuddy backend:
- SliceRequest gains filament_presets: list[PresetRef]. Validator
accepts three shapes (multi-color array, source-aware singular,
legacy bare-int id) and lands them all on a populated array before
the route handler runs — fully backwards-compatible.
- SlicerApiService.slice_with_profiles takes filament_profile_jsons:
list[str] and sends one filamentProfile multipart part per profile
(in submission order) so the sidecar receives N profiles cleanly.
- New service slice_preview runs the sidecar's slice_without_profiles
against an unsliced project file's embedded settings, parses the
result's slice_info.config, and returns the canonical per-plate
filament list. Cached by (kind, source_id, plate_id, content_hash)
with LRU eviction at 256 entries, per-key asyncio.Lock prevents
thundering-herd; transient sidecar failures are NOT cached so they
retry naturally; parse failures ARE cached (deterministic property
of the input, no point re-running).
- /filament-requirements endpoint chain: slice_info.config (existing,
sliced files) → preview-slice (new, unsliced project files) →
project_settings.config + painted-face heuristic with 5% noise
threshold (sidecar-down fallback).
- threemf_tools gains extract_project_filaments_from_3mf and
extract_plate_extruder_set_from_3mf — the latter unions object
top-level extruder, per-part overrides, and painted-face quadtree
leaves (1-E nibbles in paint_color attrs of <triangle> elements
inside per-object .model files).
- Cloud preset listing no longer fetches per-preset detail (Bambu's
rate limit at ~10/sec returns 429 on every request for users with
50+ presets). Unified-listing dedup pass instead backfills metadata
cross-tier so a cloud entry that wins dedup over a same-named local
entry inherits the local's filament_type / filament_colour.
- slice_and_persist_as_archive now reads filament_type / filament_color
from the SLICED OUTPUT's slice_info.config (via ThreeMFParser, which
already gates on used_g > 0) instead of inheriting from the unsliced
source archive. Without this, archive cards for sliced multi-color
prints showed every project-wide AMS slot — 18 swatches for a
2-color print — instead of just the filaments actually consumed.
Frontend:
- SliceModal multi-step: plate-picker first when the source is a
multi-plate 3MF, then preset dropdowns. One filament dropdown per
AMS slot the plate actually uses, each pre-picked by metadata
match against user's local + standard presets via existing
colorsAreSimilar / normalizeColorForCompare utils.
- SliceModal-only tier priority is now local → cloud → standard
(was cloud → local → standard). Other consumers of /slicer/presets
keep the existing cloud-first order.
- Submits filament_presets array; backfills the legacy singular
filament_preset from the array's first entry for stale-tab
compatibility.
- i18n keys added across all 8 locales: slice.filamentSlot,
slice.tier.{local,cloud,standard}, slice.cloud.{notAuthenticated,
expired,unreachable}, slice.noPresetsForSlot,
slice.allPresetsRequired (en + de fully translated; six others
seeded with English copies pending native translation, matching
the project's existing flow).
|
||
|
|
29e55c15fc | Updated update_website_wiki.sh | ||
|
|
988c00554e |
feat(slicer): multi-color slicing + per-plate filament discovery
The slice modal previously rendered exactly one filament dropdown and
silently truncated multi-color 3MFs to a single profile, producing wrong
colours on every multi-filament print. End-to-end fix across sidecar,
backend, and frontend.
Sidecar (orca-slicer-api / bambuddy/profile-resolver, separate commit):
- /slice accepts up to 16 repeated filamentProfile parts; slicing
service materializes each and joins paths with `;` for
--load-filaments.
- /profiles/bundled emits filament_type and filament_colour per leaf
so the bundled tier carries metadata into the modal.
Bambuddy backend:
- SliceRequest gains filament_presets: list[PresetRef]. Validator
accepts three shapes (multi-color array, source-aware singular,
legacy bare-int id) and lands them all on a populated array before
the route handler runs — fully backwards-compatible.
- SlicerApiService.slice_with_profiles takes filament_profile_jsons:
list[str] and sends one filamentProfile multipart part per profile
(in submission order) so the sidecar receives N profiles cleanly.
- New service slice_preview runs the sidecar's slice_without_profiles
against an unsliced project file's embedded settings, parses the
result's slice_info.config, and returns the canonical per-plate
filament list. Cached by (kind, source_id, plate_id, content_hash)
with LRU eviction at 256 entries, per-key asyncio.Lock prevents
thundering-herd; transient sidecar failures are NOT cached so they
retry naturally; parse failures ARE cached (deterministic property
of the input, no point re-running).
- /filament-requirements endpoint chain: slice_info.config (existing,
sliced files) → preview-slice (new, unsliced project files) →
project_settings.config + painted-face heuristic with 5% noise
threshold (sidecar-down fallback).
- threemf_tools gains extract_project_filaments_from_3mf and
extract_plate_extruder_set_from_3mf — the latter unions object
top-level extruder, per-part overrides, and painted-face quadtree
leaves (1-E nibbles in paint_color attrs of <triangle> elements
inside per-object .model files).
- Cloud preset listing no longer fetches per-preset detail (Bambu's
rate limit at ~10/sec returns 429 on every request for users with
50+ presets). Unified-listing dedup pass instead backfills metadata
cross-tier so a cloud entry that wins dedup over a same-named local
entry inherits the local's filament_type / filament_colour.
Frontend:
- SliceModal multi-step: plate-picker first when the source is a
multi-plate 3MF, then preset dropdowns. One filament dropdown per
AMS slot the plate actually uses, each pre-picked by metadata
match against user's local + standard presets via existing
colorsAreSimilar / normalizeColorForCompare utils.
- SliceModal-only tier priority is now local → cloud → standard
(was cloud → local → standard). Other consumers of /slicer/presets
keep the existing cloud-first order.
- Submits filament_presets array; backfills the legacy singular
filament_preset from the array's first entry for stale-tab
compatibility.
- i18n keys added across all 8 locales: slice.filamentSlot,
slice.tier.{local,cloud,standard}, slice.cloud.{notAuthenticated,
expired,unreachable}, slice.noPresetsForSlot,
slice.allPresetsRequired (en + de fully translated; six others
seeded with English copies pending native translation, matching
the project's existing flow).
Permissions: no new endpoint paths added. Preview-slice runs inside
/filament-requirements (LIBRARY_READ / ARCHIVES_READ) and multi-filament
dispatch runs inside POST /slice (LIBRARY_UPLOAD). No auth surface
widened.
Tests: 6 SliceRequest schema tests for multi-filament + legacy-new
precedence; 9 unit tests for slice_preview cache behaviour (LRU
eviction with lock cleanup, content-hash invalidation, concurrent
thundering-herd guard, no-cache-poison on transient sidecar failure);
15 unit tests for the two new threemf_tools helpers (5 + 10 cases
including the 60/40 painted-threshold regression pin); a multi-filament
wire-format test pinning the multipart part count + order; 22 frontend
SliceModal tests covering plate picker, multi-color render,
metadata-aware pre-pick, manual override, and the new tier order.
|
||
|
|
69b6b5a334 |
fix(#1150): skip MQTT reconnect on watchdog timeout when project_file landed
Background: P1P firmware can take ~135 s after a project_file MQTT publish
to actually start parsing the uploaded .3mf — gcode_state stays IDLE and
subtask_id doesn't advance until parse completes. The dispatch watchdogs
treated the missed transition as a #887/#936 half-broken session and called
force_reconnect_stale_session, which interrupts the printer's in-progress
parse and triggers 0500_4003 ("can't parse print file") on the printer side.
Both #1150 (slow parse) and #887/#936 (zombie session) look identical from
state and subtask_id alone — both have stale state and stale subtask_id with
fresh telemetry. The distinguishing signal is the printer's gcode_file
field: it updates in push_status when the project_file command actually
lands on the printer, but stays unchanged when the publish was silently
swallowed.
Both watchdogs (_verify_print_response in background_dispatch and
_watchdog_print_start in print_scheduler) now capture pre_gcode_file from
printer_manager.get_status() before sending the publish, then on timeout
compare it against the last good status seen during the poll loop. If the
file changed, the command landed → log a #1150 warning, skip the forced
reconnect to avoid 0500_4003 mid-parse. If unchanged, fall through to the
original force_reconnect_stale_session call so the half-broken-session
recovery is preserved exactly.
Caveat documented in code: in a retry-same-file slow-parse scenario the
gcode_file looks identical pre/post-publish, so the watchdog falls through
to the reconnect path and the user still hits 0500_4003 on that retry.
Accepted to avoid breaking the half-broken-session recovery, which is the
more impactful regression of the two.
The new pre_gcode_file kwarg has a default of None on both watchdog
functions, so any caller that doesn't pass it keeps the original
reconnect-on-timeout behavior verbatim.
4 new unit tests cover both watchdogs: skip on gcode_file change (#1150
fix), reconnect when unchanged (#936 protection preserved), skip when
pre=None and current is non-None (printer just connected), reconnect when
pre_gcode_file arg is omitted (backward-compat). All 439 existing
dispatch / scheduler / mqtt tests pass unchanged.
|
||
|
|
05f9c418ae | Housekeeping | ||
|
|
92e97a630d |
chore: drop noisy embedded-settings warning toast
SliceJobTrackerContext was emitting a yellow warning toast on every
completed slice whose result carried `used_embedded_settings: true`
(the auto-fallback path that fires when the sidecar's --load-settings
triplet rejected the input).
For 3MF inputs that fallback fires on essentially every slice in
production. End-to-end debugging via the new sidecar stderr capture
showed the BambuStudio CLI segfaults silently after one trace line
("Initializing StaticPrintConfigs") when given --load-settings over
a 3MF — even with the broader strip applied. So the toast fired on
~every completed 3MF slice and added noise without an actionable
path for the user.
Drop the toast call, drop the now-orphan slice.fallbackUsedEmbedded
i18n key from all 8 locale files. The `used_embedded_settings` flag
still lands on SliceResponse / SliceArchiveResponse for tests and
observability (test_library_slice_api.py:347 continues to pin it);
only the user-facing toast goes.
|
||
|
|
61c15aac03 |
feat(slicer): unified Cloud/local/standard presets + harden 3MF profile path
UNIFIED PRESET LISTING (the main feature)
The initial slicer integration only saw DB-backed local imports — users
without imported profiles got an empty Slice modal even when their
Bambu Cloud account or the slicer sidecar carried perfectly usable
presets. The Slice modal now pulls from three tiers in priority order:
- cloud: user's own Bambu Cloud presets, fetched live.
- local: DB-backed imports.
- standard: slicer-bundled stock profiles via the sidecar's new
GET /profiles/bundled endpoint.
Listing endpoint: GET /api/v1/slicer/presets
- Name-based dedup, cloud > local > standard, within-tier order
preserved exactly. A preset that exists in multiple tiers only
renders in the highest-priority one.
- cloud_status (ok / not_authenticated / expired / unreachable)
drives a precise modal banner instead of an unexplained empty
list.
- Cloud branch: per-user cache, 5 min TTL, key
(user_id, sha256(token)[:16]) so logout/login or token rotation
auto-invalidates without callback wiring from the cloud-auth
routes.
- Bundled branch: global cache, 1 h TTL.
- Bundled URL respects preferred_slicer (bambu_studio vs orcaslicer)
so BambuStudio installs see the bambu sidecar's bundled list, not
OrcaSlicer's.
Slicing endpoint: POST /library/files/{id}/slice + /archives/{id}/slice
- Body now accepts source-aware {source, id} triplets per slot:
printer_preset: PresetRef
process_preset: PresetRef
filament_preset: PresetRef
- Legacy *_preset_id integer fields kept for backwards-compat. The
schema validator normalises bare ints into
PresetRef(source='local', id=str(int)) so the route handler only
deals with one shape.
New preset_resolver service fetches the JSON content per source:
- cloud: BambuCloudService.get_setting_detail(id), unwraps the
`setting` envelope (falls back to top-level for minor
shape variants).
- local: DB read with preset_type slot validation (existing path,
factored into the new helper).
- standard: minimal {name, inherits, from: "system"} stub — the
sidecar's profile-resolver flattens it against
BUNDLED_PROFILES_PATH/<category>/<name>.json with no
preset-content round-trip from Bambuddy.
PERMISSIONS
- Listing route gate: LIBRARY_UPLOAD (matches the slice action — any
user who can slice can populate the dropdowns).
- Cloud branch in BOTH the listing helper and the resolver checks
CLOUD_AUTH independently — a user with LIBRARY_UPLOAD but not
CLOUD_AUTH doesn't see the cloud tier (returns 403 if they try
to slice with a cloud preset) even if a leftover User.cloud_token
survived a permission revocation. Cloud listing path
short-circuits the token lookup entirely on the gate-fail branch.
FRONTEND — SliceModal
- Calls api.getSlicerPresets() instead of api.getLocalPresets().
- Dropdowns render <optgroup> per tier with localised section
labels (Cloud / Imported / Standard).
- Default selection follows cloud > local > standard priority on
first load (auto-pick fires once when the data arrives, manual
choices stick after that).
- Cloud-status banner renders three variants
(sign-in / expired / unreachable) only when status != 'ok'.
- Slice button submits source-aware refs; legacy integer payload
is preserved server-side for older clients.
3MF PROFILE-PATH HARDENING (shipped together because they touch the
same code paths)
(1) Strip widened. _strip_3mf_embedded_settings only removed
Metadata/project_settings.config. Real-world Bambu Studio /
OrcaSlicer 3MFs also carry model_settings.config, slice_info.config,
and cut_information.xml — any single leftover trips the CLI's
input validation and the slice falls back to embedded settings,
making the SliceModal's profile picker theatrical for 3MF inputs.
Now removes all four configs via a centralised
_STRIPPABLE_3MF_CONFIGS frozenset with per-file rationale;
geometry (3D/3dmodel.model), thumbnails, multi-part data
preserved.
(2) Sidecar 5xx error capture. slicer_api.py was reading only
`message` from sidecar 5xx responses and dropping `details`, so
every CLI failure surfaced as the unhelpful generic
"Failed to slice the model". New _format_sidecar_error helper
combines both fields, falls back to plain-text body for
non-JSON 5xx (nginx 502s, gateway timeouts), replaces the four
duplicated extraction blocks. Pairs with the orca-slicer-api
fork's bambuddy/profile-resolver branch which now emits
`details` on AppError responses (d9c6121) and captures CLI
stderr in the failure path (fb928c8).
CARE TAKEN — additive on existing surfaces
- main.py: +1 import, +1 router register
- slicer_api.py: +list_bundled_profiles, +_format_sidecar_error
(dedupes the 4 message-extraction blocks);
no existing method behaviour changed
- library.py: resolver swap inside _run_slicer_with_fallback,
user_id threaded through two callers,
strip widened
- schemas/slicer.py: PresetRef added, *_preset fields added,
legacy *_preset_id kept; validator normalises
- 4 new files: schema, route, resolver, tests
- No existing route URL changed, no existing field removed, no
behaviour change for clients still sending bare integer ids.
TESTS
- 17 unit tests for the listing endpoint helpers
- 11 unit tests for the source-aware resolver
- 6 schema tests for SliceRequest legacy + new shapes
- 3 unit tests for the new sidecar error-detail capture
- Strip integration test extended to assert all 4 configs go and
geometry stays
- 12 frontend tests for SliceModal covering tier-priority
auto-selection, <optgroup> grouping, fallback paths, source-aware
payload on submit, manual override across tiers, archive vs
library routing, error display, all three banner variants
Verified: 3394 backend + 1531 frontend tests pass, ruff clean,
frontend production build clean.
Pairs with three already-pushed commits on the orca-slicer-api fork's
bambuddy/profile-resolver branch:
- 5fd6bc6 feat(profiles): add GET /profiles/bundled
- d9c6121 fix(error): include causeMessage in JSON response as `details`
- fb928c8 fix(slicing): include CLI stdout/stderr in failure causeMessage
|
||
|
|
20b21f992b | Housekeeping | ||
|
|
e082a92ad4 |
Merge pull request #1144 from maziggy/feature/slicer-api
feat(slicer): server-side slicing via OrcaSlicer / Bambu Studio sidecar
Adds an optional slicer-api/ Compose stack and wires Bambuddy's File
Manager, Archives, and MakerWorld pages to a new server-side Slice flow.
Slicing runs as an in-memory background job (POST returns 202 + job_id,
polled via GET /api/v1/slice-jobs/{id}) so a multi-minute slice no
longer pins the modal; result lands as a new .gcode.3mf in the same
folder (or new archive for archive sources) with the embedded
thumbnail extracted.
|
||
|
|
8829bc2cc6 | Merge branch 'dev' into feature/slicer-api | ||
|
|
d81e4853ec |
fix(#1112): cross-boundary file move actually relocates bytes
@Carter3DP's report on 0.2.4b1: a file moved into an external (NAS)
folder showed up in Bambuddy under that folder but was never written
to the mount. Traced to move_files only updating file.folder_id in
the DB while leaving the bytes in library_files_dir/. Direct upload
to a writable external folder was already fixed in 0.2.4b1; the move
path was not.
Cross-boundary moves now physically relocate the bytes through a new
_move_file_bytes helper. Same-boundary moves (managed -> managed)
keep the existing DB-only fast path because a managed file's on-disk
location doesn't depend on which managed folder owns it.
Four flows:
- managed -> external: copy to <mount>/<filename>, set
is_external=True, store the absolute path,
unlink the managed source
- external -> managed: copy to internal storage with a fresh UUID
name, set is_external=False, store the
relative path, unlink the external source,
recompute file_hash (scan-tracked rows
carry file_hash=None)
- external -> external: same shape as managed -> external
- managed -> managed: DB-only
Copy-then-unlink ordering means a partial copy followed by a failed
unlink leaves both copies on disk rather than losing the source if
the target write fails halfway through on a flaky NAS mount. Failed
shutil.copy2 cleans up partial dest before raising.
Defence-in-depth skips:
- source on a read-only external mount (move = delete-on-source
which a RO mount can't fulfil)
- filename collision on the target mount
- traversal-style filenames after Path.resolve()
- missing source on disk
- os.access(W_OK) on the target mount
Each skip carries a structured {file_id, code, reason} entry in a new
skipped_reasons field on the response so the UI can surface "5 of 10
skipped: 3 collisions, 2 missing on disk" instead of a blank number.
The {moved, skipped} numeric counters are preserved so existing
frontend code keeps working.
6 new integration tests in test_external_folders_api.py::
TestCrossBoundaryMove covering: managed -> external relocates bytes
(the actual fix), external -> managed relocates bytes including hash
recompute, name collision skip with the pre-existing target file
intact, source-readonly skip, managed -> managed stays DB-only, and
skipped_reasons always present.
|
||
|
|
9884018497 |
fix: cancel-safe get_db + drop sqlalchemy.pool cancellation noise
@Carter3DP's support package showed bambuddy.log filling with two
distinct cascades on long uploads:
ERROR sqlalchemy.pool Exception terminating connection ...
CancelledError: Cancelled via cancel scope
... by starlette.middleware.base
.BaseHTTPMiddleware.__call__.call_next
ERROR sqlalchemy.pool The garbage collector is trying to clean up
non-checked-in connection ... will be
terminated.
WARN backend.app.main Runtime tracking commit failed:
(sqlite3.OperationalError) database is locked
Single root cause. Starlette's BaseHTTPMiddleware (used under the hood
by every @app.middleware("http") decorator) cancels the inner task
scope when a client disconnects mid-request — common on long
multipart uploads where the client times out before the server's
response. Pre-fix get_db only caught Exception, but CancelledError
is BaseException, so cancellation skipped the rollback path entirely.
The SQLite write lock stayed held until GC reclaimed the connection
ages later, blocking every other writer in the meantime. On Postgres
the leak shape is identical; the symptom would be "QueuePool limit
... overflow" instead of "database is locked".
(1) get_db now catches BaseException so CancelledError triggers
rollback. Both rollback() and close() are wrapped in
asyncio.shield so the cleanup completes even when the await
itself is being cancelled by the same cancel scope. SQLite write
lock is released promptly; connection returns to the pool instead
of leaking until GC.
(2) CancelledPoolNoiseFilter (new filter on sqlalchemy.pool) drops
the residual records that pre-existing pools still emit during
their own cleanup. Two patterns suppressed:
- "Exception terminating connection ..." with a CancelledError
anywhere in the exc_info chain (walks __cause__/__context__
with a seen-set guard against pathological cycles)
- "The garbage collector is trying to clean up non-checked-in
connection ..." (always symptomatic of cancellation; never
independently actionable)
Real pool problems — broken connections, OSError on terminate,
pool exhaustion — keep flowing because they carry a different
exception chain or a different message prefix.
13 regression tests across test_get_db_cancel_safety.py (commit on
clean exit, rollback on regular Exception, rollback on CancelledError,
close runs even if rollback raises, close failure on clean exit
doesn't propagate, rollback + close both go through asyncio.shield)
and test_cancelled_pool_filter.py (drops cancellation-driven
terminate, drops GC-cleanup, keeps real OSError terminate, keeps
terminate without exc_info, keeps unrelated pool messages, drops
chained-cause CancelledError, defensive guard against self-referential
cause chains).
Applies to SQLite and PostgreSQL — get_db is dialect-agnostic and
the filtered messages come from base sqlalchemy.pool not from any
specific dialect.
|
||
|
|
56800589ff |
fix(#1113): silence Windows asyncio Proactor cleanup-RST noise
bambuddy.log on Windows fills with
Exception in callback _ProactorBasePipeTransport._call_connection_lost()
ConnectionResetError: [WinError 10054] An existing connection was
forcibly closed by the remote host
every time a printer / MQTT broker / camera RSTs a TCP socket instead
of FINing it. The application-layer reconnect (paho-mqtt, httpx)
handles the actual disconnect fine; the traceback is asyncio
bookkeeping. Reported by @cadtoolbox who runs 9 printers including 5
offline X1Es, so the log filled multiple times per minute.
New backend/app/core/asyncio_handlers.py installs a custom
loop.set_exception_handler on Windows that pattern-matches three
signals together (platform == win32, exception is
ConnectionResetError, asyncio message contains
_call_connection_lost) and demotes the entry to DEBUG. Genuine
ConnectionResetErrors raised inside application coroutines have a
different message string and still surface; BrokenPipeError /
ConnectionAbortedError on the same cleanup path also still surface.
Wired from lifespan startup before any task can spawn that might
trip it. Linux / macOS use the Selector loop, so install is an
explicit no-op there with a False return.
9 unit tests in test_asyncio_handlers.py covering signature match,
rejection of unrelated resets, platform gate, suppress vs.
pass-through to default handler.
|
||
|
|
02eb5f57dc |
fix(#422): start g-code anchor + slicer placeholder substitution
Auto-Print G-code Injection had two reviewer-reported bugs from the initial
ship:
1. Start snippets were prepended to the entire plate_X.gcode, landing
before the printer's own bed-heat / homing / nozzle-prime sequence —
so a Swapmod start snippet that assumed nozzle-at-temp ran on a cold
printer (pleite). Anchor injection at "; MACHINE_START_GCODE_END" so
snippets land where a slicer-side custom-start-gcode would. Files
without the marker keep prepend behaviour as a fallback with a
warning log.
2. Placeholders like "G1 Z{max_layer_z} F600" were written verbatim;
firmware parsed them as Z1 and crashed the head into the print on
tall models — real safety bug (DevScarabyte). Added a header parser
for the 3MF "; HEADER_BLOCK_START..END" block (lowercased keys,
[units] suffix stripped, spaces -> underscores) and a Prusa-style
{name} substitution pass over both start and end snippets before
injection. Supported placeholders: {max_layer_z} / {max_print_height},
{total_layer_number} / {total_layers}, {total_filament_weight},
{total_filament_length}, plus any other normalised header key.
Unknown placeholders are left verbatim with a warning — a typo never
silently expands to an empty string.
16 new regression tests across 4 new classes in test_gcode_injection.py
(anchored injection + missing-marker fallback, placeholder substitution
including alias resolution + unknown-pass-through, direct unit tests
for each new helper). All 2195 backend unit tests pass.
Wiki print-queue page updated with the supported placeholder list and a
{max_layer_z} safety callout for park moves.
|
||
|
|
d4533c3890 |
chore(deps): bump postcss to 8.5.12 to clear GHSA-qx2v-qp2m-jg93
Moderate-severity advisory: PostCSS < 8.5.10 has an XSS via an unescaped </style> sequence in its CSS Stringify output. Caret range in package.json already accepts 8.5.12, so this is a lockfile-only bump (npm audit fix). Build verified clean. Vite, autoprefixer, and @tailwindcss/postcss all dedupe onto the same 8.5.12 — no nested copies left in node_modules. Note: Bambuddy doesn't pass user-controlled CSS through PostCSS at runtime (PostCSS is build-time-only), so the practical impact even on older versions was nil. This is hygiene + clearing the npm audit warning. |
||
|
|
6deaa513af |
● feat(slicer): server-side slicing via OrcaSlicer / Bambu Studio sidecar
Adds an optional slicer-api/ Compose stack and wires Bambuddy's File
Manager, Archives, and MakerWorld pages to a new server-side Slice flow.
Slicing runs as an in-memory background job (POST returns 202 + job_id,
polled via GET /api/v1/slice-jobs/{id}) so a multi-minute slice no
longer pins the modal; result lands as a new .gcode.3mf in the same
folder (or new archive for archive sources) with the embedded
thumbnail extracted.
Backend
- New services: slice_dispatch (in-memory dispatcher, 30min retention
sweep) and slicer_api (HTTP bridge with 4xx/5xx/connection error
split that drives the 3MF embedded-settings fallback retry path).
- New schemas: SliceRequest, SliceResponse, SliceArchiveResponse,
SliceJobEnqueueResponse.
- New routes: POST /library/files/{id}/slice,
POST /archives/{id}/slice, GET /api/v1/slice-jobs/{id} (gated on
LIBRARY_READ since job IDs are sequential and the body leaks source
filenames and result IDs).
- AppSettings + env defaults: use_slicer_api, orcaslicer_api_url,
bambu_studio_api_url. DB-stored values override env defaults.
Frontend
- New SliceModal handles preset gating; enqueues then closes
immediately.
- New SliceJobTrackerProvider polls active jobs at app level, surfaces
a single toast per job (queued -> running -> completed / failed)
and invalidates library/archives queries on terminal status.
- Settings -> Workflow -> Slicer card: preferred slicer dropdown,
Use Slicer API toggle, contextual sidecar URL field.
- File Manager / Archives / MakerWorld get a Slice button gated on
the Use Slicer API setting.
- gcode-viewer adapter learns ?library_file=<id> so sliced library
files preview inline.
i18n
- New slice.* and settings.{useSlicerApi,slicerCard,orcaslicerApiUrl,
bambuStudioApiUrl,slicerApiUrlDescription,useSlicerApiDescription}
+ fileManager.noPermissionSlice keys across all 8 locales (en, de,
fr, it, ja, pt-BR, zh-CN, zh-TW). English fully translated, German
fully translated, the other six seeded with English fallbacks
pending native translation.
Tests
- 10 backend integration tests in test_library_slice_api.py covering
validation (404/400), happy-path enqueue, sidecar-down, 3MF
embedded-settings fallback, STL no-fallback, and preset-error ->
failed job paths.
- New unit tests in test_slicer_api.py for the HTTP bridge.
- 5 new SliceModal frontend tests covering preset gating, library +
archive enqueue paths, error surface, and preset-load failure.
- Existing SettingsPage tests adjusted: slicer dropdown asserts now
switch to the Workflow tab first; added a beforeEach URL reset so
one test's tab click doesn't bleed into sibling tests.
Sidecar
- New slicer-api/ folder is self-contained and optional. Two services
(orca-slicer-api on 3003, bambu-studio-api on 3001 behind --profile
bambu) build via Docker git-build-context from
maziggy/orca-slicer-api@bambuddy/profile-resolver. The fork patches
the OrcaSlicer CLI's profile compatibility quirks (inherits-chain
resolver, from:User -> system rewrite, '# ' clone-prefix strip,
sentinel-value strip) empirically required to slice real GUI
exports without segfaulting the CLI.
Docs
- CHANGELOG entry under [0.2.4b1] - Unreleased Added.
- README File Manager bullet for the new server-side Slice button.
- bambuddy-website features.html: new card under "Configurable Slicer".
- bambuddy-wiki: new page features/slicer-api.md + nav entry +
features index card.
Notes
- Opt-in: with Use Slicer API off, the existing "open in desktop
slicer via URI" flow is the default and unchanged.
- 3MF inputs that segfault the CLI on --load-settings transparently
retry with embedded settings; the resulting job carries
used_embedded_settings: true.
- Sliced files always export as .gcode.3mf so File Manager picks up
the embedded thumbnail; file_type is set to "gcode" (blue badge).
|
||
|
|
d92f48e3c6 |
feat(spoolbuddy): plate-clear pills on kiosk dashboard + toast UX polish
Adds a finger-friendly amber pill row under the printer status badges
on the SpoolBuddy kiosk dashboard. When any printer reports
awaiting_plate_clear=true, a compact pill appears showing the printer
name plus a "Clear" action; tapping it calls POST /printers/{id}/clear-plate
and optimistically removes the pill before the WebSocket round-trip
lands. Multiple pending printers wrap inline via flex-wrap so the
dashboard stays compact when several finish at once. Pill dimensions
match the existing online/offline printer badges (px-2.5 py-1, text-xs).
The kiosk auth path (X-API-Key) already passes the printers:clear_plate
permission gate via the existing _APIKEY_DENIED_PERMISSIONS denylist
(the permission is intentionally not denied — clear-plate is an
inventory-flow operation, not an admin one), so no auth wiring changes
were needed.
Two adjacent fixes shipped in the same change:
- SpoolBuddyLayout suppresses the global toast viewport while mounted.
The global ToastProvider in App.tsx wraps both the main app and the
kiosk routes, which meant the background-dispatch progress overlay
was rendering on the kiosk display alongside any in-flight prints.
Added setViewportSuppressed(bool) on the toast context; the layout
flips it via useEffect and restores on unmount. State machine and
dispatch-event subscription are untouched — only the visible
viewport is hidden.
- Dispatch toast no longer reads as "frozen at 100%" for fast uploads.
Small files complete FTP in <500ms but the bar would sit at 100%
while the printer's MQTT confirmation landed. When uploadProgressPct
>= 99.9 and status is still 'processing', the byte counter is
replaced with "Awaiting printer..." and the bar gets animate-pulse.
|
||
|
|
0334f64e37 |
fix(camera): honor ?fps=N URL parameter on /camera/<id>
CameraPage hard-coded fps=15 in the stream URL and never read the
URL query string, so /camera/1?fps=5 and similar diagnostic URLs
were silent no-ops. Sibling StreamOverlayPage already honored ?fps=;
this brings CameraPage to parity.
- Read fps via useSearchParams, default 15, clamp 1-30, fallback to
15 on non-numeric input (mirrors StreamOverlayPage's parser)
- Thread the parsed value into the stream URL builder
- 5 new tests in CameraPage.test.tsx pinning default, honored value,
clamp-above-30, clamp-below-1, and non-numeric fallback
Surfaced while triaging #1131 (H2D camera freeze). Independent of
the underlying freeze investigation; restores the diagnostic knob
the issue thread was relying on.
|
||
|
|
527f8ea471 |
fix(mqtt): #1136 reprint fails with 0500_4003 SD R/W after stuck dispatch
Reprinting from archives sometimes failed immediately with a MicroSD R/W
exception, with the printer's MQTT push referencing a 3MF from a
different unrelated archive. Once it started, every subsequent reprint
hit the same error until the container was restarted.
Root cause from @smandon's support package: paho-mqtt's client-side QoS
1 queue. When the printer's command channel goes half-broken (telemetry
flowing, publishes silently dropped — same #887/#936 pattern),
background_dispatch.py:993 hits its 15s deadline and calls
force_reconnect_stale_session(). That function was force-closing the
underlying socket so paho's auto-reconnect would kick in, but the same
mqtt.Client instance, same client_id, and same in-process QoS 1 queue
stayed alive across the reconnect. Any unacked publish from the broken
session — typically the just-sent project_file for the new archive —
got replayed verbatim on the new connection. The queue accumulates
across multiple stuck dispatches in one Python process, so by the
second or third stuck reprint there were several stale
project_file/resume/stop/clean_print_error commands queued together;
the printer latched onto whichever stale path it processed last,
couldn't find the file on its SD card, and emitted 0500_4003. Container
restart was the only thing that wiped paho's in-process queue.
Replaced socket-close with a context-aware reconnect via a new
_reset_client_for_reconnect() router:
Async-context callers (dispatch deadline, FastAPI handlers via
check_staleness) → hard-reset: client.disconnect() (broker drops
session, clean_session=True), client.loop_stop() (kills paho's
network thread and its queue), null _client, fresh connect() with
incremented client_id. New connection is genuinely empty, no replay.
Paho-network-thread callers (dev-mode probe + ams_filament_setting
zombie detection inside _update_state) → socket-close fallback.
loop_stop() from inside the network thread would self-join and
deadlock, so the safe pattern there is "close the socket and let
paho's loop detect it and auto-reconnect on the same client".
Routing decision uses asyncio.get_running_loop() — paho's callback
thread has no loop, every legitimate hard-reset caller does.
7 regression tests:
- TestForceReconnectRouting (3): sync-context → socket-close fallback,
async-context → hard-reset with disconnect()+loop_stop()+null,
state-disconnected broadcast fires once on either path
- TestHardResetClientDirect (3): helper directly — old client gets
disconnect()+loop_stop(), _client cleared, failing disconnect()
doesn't propagate so background_dispatch's await chain can't break
- TestZombieSessionDetection / TestDeveloperModeProbeTimeout (updated):
paho-thread context still goes through socket-close, preserving the
legacy contract for those paths
|
||
|
|
88b5f56eb2 |
fix: cancel = layer shift, stuck "1 problem", and dropped child-logger logs
Three bugs that surfaced together while debugging an H2D cancel:
1. Cancelling a print stamped failure_reason="Layer shift" in archives
AND left the printer card stuck on "1 problem" forever. Four causes:
(a) POST /printers/{id}/print/stop never set the user-stopped flag, so
on_print_complete couldn't override "failed" -> "cancelled".
(b) HMS-derived failure_reason heuristic mapped any module-0x0C HMS to
"Layer shift". Module 0x0C is "Motion Controller" broadly (includes
cameras, markers, AND the cancel-sequence echo 0C00_001B). Real
layer-shift codes live in module 0x03. Same false-positive class
existed for "Filament runout" (any 0x07) and "Clogged nozzle" (any
0x05). Replaced with a 23-code curated short-code map; unknowns
leave failure_reason=None.
(c) Cancel-echo HMS codes (0300_400C "The task was canceled.",
0500_400E "Printing was cancelled.") were polluting state.hms_errors
via both the hms[] and print_error parse paths. Filter them at
parse time so the frontend never sees them.
(d) Frontend bucketed gcode_state="FAILED" as a problem unconditionally.
Real failures attach an HMS error; user-cancels don't — so FAILED-
without-HMS now buckets as "finished" and only escalates to "error"
when there's an active known HMS.
2. logs/bambuddy.log was silently dropping records from named child
loggers. TraceIDFilter was attached to root_logger, but Python's
logging only invokes a Logger's filters on records originating at that
logger — propagated child-logger records skipped it, formatter raised
KeyError, handler.handleError dropped the record. Moved the filter
from root_logger.addFilter() to handler.addFilter() on each handler,
matching the filter's own docstring guidance.
derive_failure_reason() extracted as a pure function for testability.
status="cancelled" now symmetrically yields "User cancelled" alongside
"aborted".
20 regression tests across:
- backend/tests/unit/test_failure_reason_derivation.py (11)
- backend/tests/unit/services/test_bambu_mqtt.py::TestHMSUserActionFiltering (4)
- backend/tests/unit/test_trace.py::TestFilterMustBeAttachedToHandlerNotLogger (1)
- frontend/src/__tests__/pages/PrintersPageBucketing.test.ts (5; includes
the H2D-cancel-echo "FAILED + only unknown HMS" case)
|
||
|
|
3e18d42613 |
fix(settings): deleted API key stays on screen until manual reload
The delete-API-key mutation called queryClient.invalidateQueries on the ['api-keys'] cache, which in react-query v5 should also refetch active queries — but in practice the deleted row remained visible until the user reloaded the page. Switched onSuccess to setQueryData so the deleted key is filtered out of the cache synchronously the moment the API confirms; no refetch round-trip required, no invalidation→refetch race possible. Create-path is unchanged (invalidateQueries was working there). Pins the contract with a new SettingsPage test that mocks the GET/DELETE endpoints, clicks delete, confirms, and asserts the row is gone without any reload. |
||
|
|
e9200449ae |
fix(deploy): kiosk picks up new builds without operator intervention
Reproduced live during the #1133 rollout: the SpoolBuddy display kept serving the pre-fix picker for hours after every cache-clear, chromium-restart, and pkill attempt because a chain of stale state across HTTP cache + Service Worker + persistent profile prevented fresh code from reaching the running tab. Three independent changes — any one of them sufficient on a clean profile, but all three needed to escape an already-corrupted one: (1) backend/app/main.py — index.html now served with Cache-Control: no-cache, must-revalidate on both / and the SPA catch-all. Vite emits content-hashed JS/CSS bundle filenames so the assets themselves are safe to cache forever, but the HTML wrapping them is the only file that knows which hash is current. Without explicit cache directives Chromium falls back to heuristic caching (typically 10% of time since Last-Modified) and on long-running kiosks happily serves stale HTML across browser restarts. That stale HTML references an old bundle hash which is also still in disk cache, so the kiosk runs pre-deploy JS forever without ever knowing why. (2) frontend/public/sw.js — CACHE_NAME bumped from bambuddy-v25 to bambuddy-v26 so any client that fetches the new sw.js drops its old CacheStorage. The SW does network-first for HTML/JS/CSS but intercepts and falls back to cache, and cache-control on HTTP responses doesn't reach into the SW's own cache layer. (3) spoolbuddy/install/install.sh — generated kiosk launcher now uses --user-data-dir=/tmp/spoolbuddy-kiosk-userdata with a pre-launch rm -rf, so every kiosk restart starts from a clean slate (no HTTP cache, no SW registration, no IndexedDB). Trade-off is a slightly slower first paint and zero offline support; neither matters for a single-purpose kiosk facing a backend on the same LAN, and the guarantee that next-deploy-just-works is worth far more. 4 new tests in test_static_html_cache_headers.py: index.html on / and SPA catch-all paths emit Cache-Control: no-cache, must-revalidate; API routes are unaffected (no leak of HTML cache directive onto endpoints we want React Query to cache aggressively). For existing kiosks already trapped by an old persistent profile, operator runs once: rm -rf ~/.config/chromium && systemctl restart getty@tty1.service. The new launcher then picks up automatically. |
||
|
|
11fe68578e |
fix(#1133): list every spool in the AMS-slot inventory picker
The picker that opens from <FilamentHoverCard> / SpoolBuddy's slot-action
sheet had two stacked filters that together blocked a real workflow:
1. AssignSpoolModal only listed spools whose tag_uid AND tray_uuid were
both null, hiding any Bambu Lab spool that had been auto-created from
RFID or scanned via SpoolBuddy NFC.
2. FilamentHoverCard rendered its inventory section only when the slot's
vendor was not 'Bambu Lab', so even with #1 fixed the assign button
wasn't visible on a BL slot.
Both filters blocked the same use case: a user has a Bambu Lab spool in
inventory but doesn't want to scan via SpoolBuddy NFC every time and
just wants to pick it from the list.
Both gates removed. Modal lists every spool that isn't already taken by
another (printer/ams_id/tray_id) tuple. Hover-card inventory section
renders for every vendor including Bambu Lab. The AMS-vs-external
special-case in the modal collapsed too — external slots used to be the
only path that allowed picking a tagged spool, that distinction is gone.
Empty slots lost their assign affordance entirely. A physically empty
slot has no spool to attach an inventory record to, and offering the
action there only led to users assigning the wrong spool to a slot the
printer hadn't actually loaded yet. Bambuddy: EmptySlotHoverCard's
inventory prop removed; PrintersPage drops the matching inventory
props on three sites. SpoolBuddy: slot-action picker gates the
assign/unassign block on slotActionPicker.tray !== null.
Modal also gets defensive hardening from the rollout investigation:
its own dedicated cache key (['inventory-spools', 'assign-modal']) so
it can't be poisoned by other components calling getSpools() with
different includeArchived args; getSpools(true) + client-side
!archived_at filter so the picker sees the full inventory regardless
of cache priming order; "Show all spools" toggle now bypasses BOTH
filters (was only bypassing material/profile, label was a lie); a
small "X fetched · Y archived · Z assigned" counter in the empty state
so future "missing spool" reports are debuggable from screenshots.
i18n.inventory.noManualSpools renamed to inventory.noAvailableSpools
with new copy ("No spools available. Add a spool to your inventory or
unassign one from another slot first.") since the empty-state premise
changed. Localised across all 8 languages.
15 frontend tests: assign/unassign render for vendor: 'Bambu Lab',
non-BL vendors unchanged, EmptySlotHoverCard renders no assign
affordance, configure button still works on empty slots, picker lists
BL spools alongside manual ones, picker drops spools assigned
elsewhere unless toggle is on, picker drops archived spools always,
toggle escape-hatch shows everything, empty-state copy update.
SpoolBuddy unassign now invalidates both ['spool-assignments'] and
['spool-assignments', printerId] so the modal's cache stays fresh
(dual cache-key consolidation deferred to its own PR).
|
||
|
|
096bdd92a8 |
fix(#1128): broadcast printer_status when awaiting_plate_clear flips
awaiting_plate_clear is a Bambuddy-side flag, not a printer-side one,
so toggling it does not produce an MQTT push from the printer. Commit
|
||
|
|
1878d2aab5 |
feat(observability): trace ID column on every log line + X-Trace-Id header
Builds on the recent uvicorn-access-log-into-bambuddy.log change.
Until now the access line told us who called an endpoint, but there
was no way to tie that line to the application records emitted on the
server side while handling that request. The rogue stop_print mystery
on 2026-04-26 left exactly that gap: even with access logs piped in,
correlating "this POST landed" with "this MQTT publish went out 6 ms
later" required eyeball-matching timestamps across different loggers.
A new ContextVar + middleware + logging filter wire a trace ID through
every record:
* trace_id_middleware mints an 8-char hex ID per request (or honours
a sane inbound X-Trace-Id for cross-system correlation), stores it
in trace_id_var (ContextVar), echoes it on the response as
X-Trace-Id, and resets the var in finally.
* TraceIDFilter, attached to root + uvicorn.access, copies the
current trace_id_var value onto every LogRecord so the format
string [%(trace_id)s] resolves to the right ID per record.
* Records emitted outside any request scope (startup, MQTT
callbacks, scheduler) get a stable "-" placeholder so the column
stays visually aligned and grep stays simple.
ContextVars are the right plumbing because asyncio copies the current
context into every asyncio.create_task, so background work spawned
from inside a request inherits the same ID without explicit threading.
request.state can't make that hop. The logging filter also has no
access to the FastAPI request object — it runs synchronously inside
the stdlib logging machinery — and the ContextVar is the only
mechanism that bridges async request scope to sync log emission.
Inbound X-Trace-Id is hard-validated against [A-Za-z0-9_-]+ (max 64
chars) before being honoured — a hostile/buggy caller cannot smuggle
log-injection payloads (newlines, control chars, megabyte blobs) into
bambuddy.log via the trace ID column; values that fail the gate
silently trigger a freshly minted server-side ID rather than failing
the request.
Middleware is decorated AFTER auth_middleware on purpose: Starlette
stacks @app.middleware decorators LIFO so the last-decorated runs
first inbound, making trace stamp the OUTERMOST layer — auth log
lines and every record emitted on the way down to and back from the
route handler all carry the same ID.
Output now correlates as:
2026-04-26 09:51:39,152 INFO [uvicorn.access] [a4f3b1e7] - "POST
/api/v1/printers/1/print/stop HTTP/1.1" 200
2026-04-26 09:51:39,158 INFO [bambu_mqtt] [a4f3b1e7] [SERIAL] Sent
stop print command
One grep a4f3b1e7 returns the full causality chain.
30 new tests: 22 unit (ContextVar placeholder, filter copies value,
asyncio task propagation, concurrent-request isolation, hex generator
uniqueness, hostile-payload validator, max-length boundary, all four
write verbs survive, GET/HEAD/OPTIONS dropped, URL-substring false-
match guards, edge cases) and 8 integration (X-Trace-Id round-trips,
body matches header, hostile inbound replaced, overlong inbound
replaced, ContextVar resets after request, generator format stable,
each request gets unique ID).
|
||
|
|
352e619ad7 |
fix(inventory): serialise spool auto-assign per printer to fix Postgres race
Bambu MQTT can deliver two ams_data push frames for the same printer
~30 ms apart (observed on H2D + dual AMS at K-profile-load / RFID-read
boundaries). Each frame triggers on_ams_change in main.py, whose
auto-assign block reads (printer_id, ams_id, tray_id), decides "no
existing assignment", and INSERTs via auto_assign_spool — and the two
callbacks raced in their respective sessions, both deciding to insert,
with the second commit losing on:
asyncpg.exceptions.UniqueViolationError: duplicate key value
violates unique constraint
"spool_assignment_printer_id_ams_id_tray_id_key"
DETAIL: Key (printer_id, ams_id, tray_id)=(1, 0, 0) already exists.
SQLite's WAL serial-write semantics had been silently swallowing the
race for ~7 weeks since the spool-assignment feature shipped (latent in
|
||
|
|
60d0c33172 |
fix(camera): catch RuntimeError in TLS proxy forwarders for uvloop
The bidirectional forwarders inside create_tls_proxy._handle catch
(ConnectionError, OSError, asyncio.CancelledError) on writes, but
uvloop's UVStream.write raises a plain RuntimeError from
UVHandle._ensure_alive when the underlying handle is already closed.
asyncio's default selector loop reports the same situation as
ConnectionResetError, so the bug only surfaced on uvloop — and only at
the moment ffmpeg (or a snapshot-capture subprocess) dropped its socket
while the proxy was mid-flush.
The RuntimeError slipped past the except tuple, escaped the forwarder
coroutine, and asyncio's client_connected_cb task-exception handler
logged a noisy multi-line traceback ending in:
RuntimeError: unable to perform operation on
<TCPTransport closed=True ...>; the handler is closed
Adds RuntimeError to the except tuple in both _fwd_to_server and
_fwd_to_client (the latter is the actual frame from the bug report —
server→client is where buffered TLS chunks land after the client has
gone). The forwarders are intentionally fire-and-forget on tear-down;
the existing dst.close() in the finally block already handles cleanup.
No functional regression possible — the connection is already dead by
the time the exception fires; this only changes whether asyncio logs an
"Unhandled exception" trace for it.
2 new regression contract tests in test_camera_tls_proxy.py use
inspect.getsource to assert both forwarder closures' except clauses
include RuntimeError. Source-level rather than a runtime test because
the forwarders are nested closures inside _handle and extracting them
just for testability would require a pure-cosmetic refactor.
Latent since
|
||
|
|
9d0418688c |
fix(#1134): propagate background-dispatch watchdog timeout as job failure
Follow-up to #1042. The post-dispatch watchdog _verify_print_response was fire-and-forget — it correctly detected when the printer never transitioned (HMS error pending, half-broken MQTT session, plate-clear gate, SD card fault) and force-reconnected the MQTT session, but the dispatch job had already been marked successful on the optimistic MQTT-publish-acknowledged path. The UI carried on showing "Print started successfully" while the printer sat idle. The watchdog now returns bool and is awaited inline by both call sites in _run_reprint_archive and _run_print_library_file. On False the call sites raise a RuntimeError carrying a user-actionable message ("Printer did not acknowledge print command — state still {pre_state}. Check the printer for a pending error...") which routes through the existing _run_active_job → _mark_job_finished(failed=True) → background_dispatch WS broadcast path. Library-file flow rolls back the freshly-created archive on timeout so no phantom row is left behind for a print that never started. The watchdog now also accepts subtask_id advancing past pre_subtask_id as a definitive "command landed" signal — same as the queue-side watchdog at print_scheduler.py:1992 — so slow H2D FINISH→PREPARE transitions (~50 s observed) don't false-fail when the printer has clearly accepted the project_file but is still in FINISH. Default timeout raised from 15 s to 90 s to match the queue-side watchdog and give the same headroom on both dispatch paths. Brief mid-window MQTT disconnects keep polling instead of immediately failing — matches what the queue watchdog already does and avoids false-failing on transient telemetry gaps. 11 new tests in test_background_dispatch_watchdog.py: state-change pickup, subtask_id-change pickup with state still FINISH, neither-changed timeout plus force_reconnect_stale_session call, pre_subtask_id=None backwards- compat, post-dispatch subtask_id=None not counting as a change, brief disconnect not short-circuiting the window, persistent disconnect for the full window returning False, default-timeout=90s contract, _run_reprint_archive raises RuntimeError with the captured pre-state args on watchdog False, _run_reprint_archive happy path doesn't rollback, _run_active_job marks the job failed with the message when _process_job raises RuntimeError. |
||
|
|
e1b4257953 | Post work PR #1126 | ||
|
|
4304a42542 |
feat(#729): per-spool category + low-stock threshold override
Two new optional fields on Spool: free-text `category` (max 50) and `low_stock_threshold_pct` (1-99). Powers the "differentiate critical spools from prototype spools and alert at different thresholds" use case from #729 without taking on the full multi-tag taxonomy + auto- apply rules + per-tag alert system the ticket originally proposed. Form gains: - Category input with datalist autocomplete sourced from categories already in use, so casing/spelling stays consistent. - Per-spool low-stock threshold input. Empty = global default; the global value renders as the placeholder. Inventory page: - New category filter chip (hidden until at least one spool carries a category — keeps the chip row uncluttered). - Stat-card "Low Stock" count and the "Low Stock" filter both honour the per-spool override. Plus: rename "Delete Tag" button to "Clear RFID Tag" (the original ticket reporter mistook it for a taxonomy-tag delete; the button actually clears the RFID UID/UUID off the spool record). Toast key renamed from `tagDeleted` to `rfidCleared`. i18n: full translations across all 8 locales. Tests: 9 new backend schema tests (defaults, partial-update, range rejection, max-length); 2 new frontend tests (per-spool threshold pulls extra spools into low-stock count, filter chip hidden when no categories exist). |
||
|
|
568835c586 |
fix(#918): RFID auto-match handles Quick-Add and rejects non-Bambu brands
`find_matching_untagged_spool` is supposed to attach an incoming Bambu
RFID UUID to a pre-existing manually-logged spool of the same
material/color so users who log inventory before scanning don't end up
with duplicate rows. Two bugs meant it almost never worked for the
actual reporting workflow:
1. Subtype filter was strict. AMS reports `tray_sub_brands="PLA Basic"`
→ matcher required `Spool.subtype = 'Basic'` exactly. The form's
Quick-Add mode only requires `material`, so bulk-logged rows have
`subtype=NULL` and were always excluded → duplicate on first AMS
read.
2. Brand wasn't filtered. The docstring claimed brand was matched but
the WHERE clause didn't include it, so a same-color Polymaker (or
any non-Bambu) untagged row could acquire a Bambu UUID — silent
data corruption.
Fix in the same query: subtype prefers exact match but accepts NULL as
fallback (CASE in ORDER BY ensures exact wins when both exist); brand
restricted to NULL or LOWER(brand) LIKE '%bambu%' (covers 'Bambu',
'Bambu Lab', 'BambuLab', 'bambu lab' — the spellings users actually
type).
6 regression tests added in test_spool_tag_matcher.py.
|
||
|
|
35edc036bd |
feat(notifications): per-event ntfy priority headers (#990)
ntfy supports a Priority header (1=min, 2=low, 3=default, 4=high, 5=urgent) that controls escalation on the receiving device, but every event was being sent at the server default — so a "50% complete" ping looked identical to "print failed" or "printer offline". Add a per-event priority dropdown section in the Add/Edit Notification modal (visible only for ntfy, listing only enabled events); the backend reads config.event_priorities and emits the matching Priority header on POST and PUT (image-attachment) paths. Unmapped events fall through to the ntfy server default. Out-of-range and non-numeric values are dropped, not clamped, so a misconfigured value never silently sends at the wrong urgency. Test sends omit the header by design so the test path can't accidentally page someone at urgent priority. Backward compatible: existing providers without event_priorities behave exactly as before. NtfyConfig.event_priorities is optional; the route stores config as a JSON blob so no migration is needed. i18n: full translations across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/ zh-TW). README, CHANGELOG, and the wiki notifications page updated. Tests: 6 backend (Priority set on mapped, omitted on unmapped/missing/ no-priorities, ignored for bad values, propagated through attachment path), 6 frontend (section visible only for ntfy, lists only enabled events, save round-trip, edit pre-fill, toggle drops row, non-ntfy never writes the key). |
||
|
|
30cf384b5a |
fix: render Swagger UI at /docs with a docs-scoped CSP
The global CSP set script-src 'self', so FastAPI's /docs page rendered blank: the inline boot <script> and the cdn.jsdelivr.net swagger-ui bundle/CSS were both blocked. /redoc and /docs/oauth2-redirect had the same problem. Branch the security_headers_middleware to emit a docs-scoped CSP for those three paths that allows cdn.jsdelivr.net (scripts + styles), the FastAPI/Redoc favicon hosts (images), and 'unsafe-inline' for the inline boot script. Every other route keeps the stricter SPA policy unchanged. |
||
|
|
12c01f029d |
Revert "feat(oidc): Azure Entra ID support — configurable email claim & verification + Remember Me persistent login (#1118)"
This reverts commit
|
||
|
|
fcda728af4 |
feat(#1108): long-lived camera-stream tokens + fix(#1089) audit-pass tweaks
#1108 — Long-lived camera-stream tokens for HA / Frigate / kiosks. Camera-only V1, hard 365-day cap (no infinite tokens), pbkdf2 hashed at rest, plaintext shown to user exactly once on creation. New "Camera API Tokens" panel under Settings → API Keys with self-service create/revoke, styled confirm modal, admin "All users" view for leak triage. Auth path: /camera/stream tries the existing 60-min ephemeral table first, falls through to the long-lived path. Indexed lookup_prefix keeps verify O(1) per token. Permission audit: gated the existing API-keys-CRUD + Webhook docs + API Browser content behind api_keys:read so non-admins with camera:view land on the API Keys tab and see only the Camera Tokens panel they actually have permission to use. Grid layout collapses to single column for non-admins. Tests: 29 new backend (15 service + 14 integration covering create/list/ revoke ownership rules, the auth fall-through, scope enforcement, prefix collisions) + 6 new frontend tests for the section UI including the new modal flow. All 77 backend tests + 21 frontend camera tests pass. Ruff clean (lint + format). Docs: README updated with fan-out + long-lived-token bullets. Wiki gets a new "Long-Lived Camera Tokens" section under features/camera.md (HA YAML example, security model, permission requirements, revoke flow). Website features.html gets the bullet under Camera Streaming. Also includes #1089 follow-up tweaks already merged in this branch: _stream_start_times.setdefault for accurate stream_uptime, subscribe() RuntimeError retry to close the grace-vs-subscribe race, atomic unsubscribe count via the iter_subscriber on_unsubscribe callback. |
||
|
|
1e3ad697f2 |
fix(#1089): camera stream fan-out broadcaster
Most Bambu Lab printers only allow one concurrent camera connection, but
GET /printers/{id}/camera/stream opened a fresh upstream per viewer.
Two browser tabs → second viewer fails or kicks the first off.
New MjpegBroadcaster (services/camera_fanout.py) owns one upstream per
printer and fans MJPEG chunks out to N subscribers. 5 s grace window
absorbs tab refreshes without reconnecting. Bounded subscriber queues
drop frames for slow viewers rather than blocking the broadcaster.
Audit-pass fixes:
- _stream_start_times set with setdefault() so stream_uptime reflects
the shared upstream's age, not the most-recent viewer's
- subscribe() retried once on RuntimeError to close a tiny grace race
- unsubscribe() returns post-removal count atomically so the detach log
no longer races with concurrent leavers
Permission gates unchanged; broadcaster has no FastAPI surface.
Tests: 13 broadcaster unit tests + 2 integration tests on /camera/stop.
External-camera path untouched.
|
||
|
|
23ef821851 |
feat(#1115): add nozzle icon to dual-nozzle status card
The dual-nozzle active-extruder card was the only tile in the printer status row without a theme icon, making the row look uneven on H2D / H2S / H2C. Adds a schematic nozzle icon (filament body + heater block + tip) matching the SVG @m4rtini2 contributed, sized and coloured to match the adjacent Nozzle/Bed/Chamber temperature cards. |
||
|
|
7f11618e1e |
Revert "feat(oidc): Azure Entra ID support — configurable email claim & verification + Remember Me persistent login (#1103)"
This reverts commit
|
||
|
|
794cb6c6bd |
fix(#1112): write uploads to external folders through to the mount
POST /library/files only rejected the read-only external branch and then unconditionally wrote to get_library_files_dir() with a UUID filename. The resulting LibraryFile row pointed at the external folder via folder_id, so the file showed up in Bambuddy's UI, but the bytes physically lived in archive/library/files/ and never touched the mount -- invisible from any other machine accessing the NAS/SMB share. Writable external uploads now write through to <external_path>/<filename> with the original filename preserved, and the DB row matches what scan produces (is_external=True, file_path=<absolute mount path>). Collisions return 409 instead of silently overwriting; inaccessible or non-writable mount returns 400; path-traversal filenames are rejected via resolve + relative_to. Extract-zip is now rejected against any external folder (not just read-only) with a clear "extract on the mount and run Scan" message -- the nested-subfolder creation path would need mkdir on the mount plus matching is_external LibraryFolder rows, which is a separate design. Scan already handles that shape. |
||
|
|
08601b4772 |
● fix(#1111): advance queue item when print fails before reaching RUNNING
When a file sliced for the wrong nozzle size is dispatched, the printer goes IDLE -> PREPARE -> FAILED without ever entering RUNNING. Completion detection required prev=RUNNING or _was_running=True, so on_print_complete never fired and the queue item stayed at "printing" forever -- blocking every subsequent pending item for that printer (check_queue seeds busy_printers from any row in 'printing'). Fire completion on FAILED from PREPARE or SLICING too. Restricted to those two pre-print states so a stale FAILED on first connection (prev=None) still can't accidentally advance an unrelated queue item. Also populate PrintQueueItem.error_message from the current HMS error list via the existing hms_errors.py lookup, so users see e.g. "[0500_4038] The nozzle diameter in sliced file is not consistent with the current nozzle setting" instead of a blank failure reason. |
||
|
|
9e938cbc8c |
Revert "feat(inventory): unified Spoolman inventory UI + Storage Location + AMS deep-link + SpoolBuddy NFC write support (#1063)"
This reverts commit
|
||
|
|
2c482572f3 |
Revert " fix(spoolman): allow LAN Spoolman in SSRF guard"
This reverts commit
|
||
|
|
4416fd4577 |
fix(spoolman): allow LAN Spoolman in SSRF guard
The SSRF guard added in this PR rejected all RFC-1918 private and loopback
addresses, which breaks Bambuddy's primary deployment topology — Spoolman
running on the same LAN as Bambuddy (192.168.x.x, 10.x.x.x, 127.0.0.1).
Users hit "Spoolman URL must not point to a private, loopback, link-local,
multicast, or unspecified address" on legitimate setups.
Rescope the guard to block what's actually dangerous in this context:
cloud metadata endpoints (AWS/Alibaba IMDS), multicast, unspecified,
non-http(s) schemes, and numeric-encoded IP bypasses. Loopback and
RFC-1918 ranges are now explicitly permitted.
Tests:
- test_ssrf_blocked_schemes_and_addresses updated with refined block list
- test_ssrf_allows_lan_spoolman_topologies (new) asserts loopback +
RFC-1918 are accepted so this regression cannot recur silently
- TestSpoolmanInventorySSRFSpoolBuddyPath parametrize lists trimmed
|
||
|
|
9c5c2a765f | Post work PR #1070 | ||
|
|
c0b6010269 |
fix(virtual-printer): cert-renewal restart regression + clipboard leak
1. `_cancel_restart_task` self-await guard (manager.py:389-413).
stop_server() / stop_proxy() are called from inside
_restart_for_cert_renewal, which runs AS _cert_restart_task.
Cancelling+awaiting self flagged a CancelledError on the next
`await` in stop_server, tearing down old listeners but never
letting start_server run — the VP sat on the expired cert
until the process was manually restarted, silently defeating
auto-renewal. Skip when `task is asyncio.current_task()` and
just clear the reference.
2. Clipboard fallback textarea leak (VirtualPrinterCard.tsx:66-81).
The HTTP fallback created a hidden textarea, called
select() + execCommand('copy'), then removed the textarea.
If select() or execCommand threw, removal never ran and the
textarea leaked into the DOM. Move the removal into `finally`
so it happens regardless of the inner block's outcome.
Regression tests in test_tailscale.py::TestCancelRestartTaskSelfAwait
cover both the self-cancel path (must NOT cancel self) and the
outside-cancel path (must still cancel and await).
|
||
|
|
e927ccefb1 |
feat(docker): Tailscale integration support via host socket mount
Add the Tailscale CLI to the production image and document how to
enable Let's Encrypt cert provisioning for virtual printers from a
Docker-deployed Bambuddy.
- Dockerfile installs `tailscale` from the official Debian repo. Only
the CLI is used at runtime; tailscaled itself stays on the host.
The binary is harmless if the socket isn't mounted — the code logs
an actionable hint and falls back to self-signed certs.
- docker-compose.yml adds a commented-out volume mount for
/var/run/tailscale/tailscaled.sock with inline setup instructions.
- tailscale.py's docker-socket hint now also fires when the binary is
present but the daemon socket is unreachable (i.e. the new Docker
pattern), not just when the binary is missing, so users get the
actionable "mount the socket" message instead of opaque CLI stderr.
Enabling the integration on a Docker host:
1. `curl -fsSL https://tailscale.com/install.sh | sh` on host
2. `sudo tailscale up`
3. `sudo tailscale set --operator=<user>` for the container PUID
4. Uncomment the tailscaled.sock mount in docker-compose.yml
5. `docker compose up -d --force-recreate`
6. Flip the Tailscale toggle on the VP card
|
||
|
|
a00ce61064 |
fix(virtual-printer): clipboard fallback for HTTP (non-secure) context
The Tailscale FQDN copy button used only `navigator.clipboard.writeText`,
which browsers block when `window.isSecureContext === false` — i.e. when
Bambuddy is reached over HTTP on a LAN / tailnet IP, which is the
common case. My catch block swallowed the error and the generic
"Failed to update settings" toast fired instead of actually copying.
Add a legacy `document.execCommand('copy')` fallback via a hidden
textarea for non-secure contexts. New i18n key
`virtualPrinter.toast.copyFailed` added to all 8 locales for the
(rare) both-paths-fail case.
|
||
|
|
b99ceb26ed |
fix(db): dedupe legacy settings rows and add missing UNIQUE(key) index
Legacy SQLite installs created the `settings` table without a UNIQUE constraint on `key`. The seed loop's `INSERT OR IGNORE` silently degraded to a plain INSERT, so every `systemctl restart` added another row of `advanced_auth_enabled` / `smtp_auth_enabled`. After a handful of restarts, `scalar_one_or_none()` in is_advanced_auth_enabled() and similar sites blew up with `MultipleResultsFound`, 500'ing the login flow. Run-migrations now deletes dup rows (keeping MIN(id) per key) and creates the missing `ix_settings_key` unique index before the seed loop. Both ops are idempotent — fresh installs and Postgres already have the index, so they no-op. |
||
|
|
6a426c74d5 | Updated install/install.sh | ||
|
|
37231d9991 | Updated install/install.sh | ||
|
|
e8f252d2b8 | Post work PR #701 | ||
|
|
1b284a9e39 | Updated CONTRIBUTING.md | ||
|
|
0cf7a11f46 |
fix(#1105): recognise new H2C serial prefix "31B8B" for dual-nozzle detection
Bambu started shipping H2C units with a new serial prefix (`31B8B…`
observed on a January 2026 unit) instead of the legacy `094…` shared by
the H2D/H2C/H2S family. Two serial-prefix-driven paths — the K-profile
edit branch in `kprofiles.py` and the delete-K-profile MQTT command in
`bambu_mqtt.py::delete_kprofile` — were silently routing the new units
through the single-nozzle format.
Match on 5 chars (`31B8B`): covers the 3-char model code plus the two
revision bytes, leaving the revision-letter slot free to iterate. This
mirrors the X2D precedent of using a longer-than-3-char prefix when a
single data point can't confirm family reuse.
Runtime dual-nozzle detection via `device.extruder.info` count and
model-string branches (`self.model in ("H2C", "H2D", …)`) are already
prefix-agnostic — no change needed there.
- backend/app/api/routes/kprofiles.py: add "31B8B" to is_h2d tuple
- backend/app/services/bambu_mqtt.py: same in delete_kprofile
- backend/tests/unit/services/test_bambu_mqtt.py: regression test
`test_h2c_new_prefix_uses_dual_nozzle_format`
|
||
|
|
689bc04d7b |
● feat(#1008): honour reprint dates in archive purge + clarify purge UX
Fix a silent correctness bug: archive purge used `created_at` which is
pinned to the first print, so reprinting a two-year-old archive yesterday
would still make it eligible for a 365-day purge. The preview and purge
queries now age each archive by `COALESCE(completed_at, started_at,
created_at)` — reprints refresh the clock.
Also flesh out both purge modals (File Manager + Archives) with an
explicit "What happens when you click Purge" effects list so users see
upfront that library files go to Trash (reversible) while archives are
hard-deleted (irreversible), plus what disk artefacts get removed.
Backend:
- services/archive_purge.py: `_last_activity_expr()` helper used by
preview, purge, and sample query
- tests/integration/test_archive_purge_api.py: new test covering the
reprinted-archive case
Frontend:
- PurgeOldFilesModal / PurgeArchivesModal: new effects bullet list
- i18n: reprint-aware ageLabel/description/warning and effects bullets
across all 8 locales (en/de fully translated, rest English fallback)
Docs:
- wiki/features/archiving.md: "How old is measured" note + effects list
- wiki/features/file-manager.md: "What happens when you click Purge"
section + explicit age-rule breakdown
- CHANGELOG: archive auto-purge entry rewritten to mention reprint
semantics, `archives:purge` permission backfill, and updated test count
|
||
|
|
c3e4506275 | Updated Dockerfile.test | ||
|
|
d8f16824f6 | Updated docker-compose.test.yml |