mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-01 03:31:25 +02:00
d82f4e032fefdb9269fe906ce50c6311cc3cd784
278
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d82f4e032f |
fix(inventory): handle PFCN cloud preset IDs in assign-via-MQTT (#1648)
Reporter on an H2D + Polymaker PLA Matte spool noticed that assigning
the spool from the Dashboard left the slicer's filament dropdown
showing "unknown", but clicking Configure right after made the
slicer recognize it correctly. "Configure" felt like a mandatory
follow-up step rather than a refinement.
Bambu cloud uses three preset-ID shapes:
GFS… — Bambu official cloud preset
PFUS… — cloud user-created preset
PFCN… — cloud shared / partner preset (Polymaker's "(Custom)"
Bambu Lab H2D variants ship this prefix)
apply_spool_to_slot_via_mqtt only routed GFS and PFUS through the
cloud-detail lookup that extracts the underlying filament_id. PFCN
slipped past the cloud-lookup branch, fell into the local-preset
int() parse path, raised ValueError, dropped into
normalize_slicer_filament which returns any P-prefix unchanged, and
the raw PFCN landed in tray_info_idx. The printer's calibration
table can't index that, so the slicer rendered "unknown". The
Configure modal rescued every assign because it does its own
getCloudSettingDetail and writes the resolved filament_id.
Extend the cloud-detail-lookup branch (inventory.py:129) and the
discard safety net (inventory.py:223) to include PFCN alongside
GFS/PFUS. Three behaviours fall out:
* Cloud-authenticated: the real filament_id from
detail["filament_id"] ships as tray_info_idx (Polymaker PLA
Matte resolves to GFL05).
* Cloud unavailable: raw PFCN discarded, the slot reuses an
existing valid P-prefix preset if material matches.
Source comment now lists all three cloud-ID shapes so the next time
Bambu invents a new prefix the maintainer doesn't have to re-derive
the structure from a bug report.
|
||
|
|
19073f5c84 |
fix(vp): auto-derive access code from target printer in non-proxy modes
Non-proxy VPs (Archive / Review / Queue) with a target printer set up a live-mirror bridge that forwards the slicer's MQTT and RTSPS auth bytes through to the real printer. The slicer holds one code in its profile (the one it bound the VP with), and that code has to satisfy both the VP listener and the real printer at the far end of the bridge. If the codes diverge the bridge silently fails at the second hop — slicer reaches .49:8883, FINs before sending a ClientHello, retries identically. The wiki framed the code-match requirement as a camera-only concern; it isn't, all bridged protocols inherit. Fix removes the foot-gun instead of re-documenting it. When a target is selected on a non-proxy VP the access-code field switches to a read-only display showing the target's code with an Eye-toggle reveal; the backend auto-inherits on every create / update (any explicit access_code submitted alongside a target is silently overridden as belt-and-braces for non-UI clients). The required-when- enabling check now treats target-set as satisfying the access-code requirement. Standalone (no-target) non-proxy VPs still get the editable input + Save button. One-shot startup migration corrects any pre-existing mismatched rows: SELECTs diverged VPs and logs one INFO line per row for the audit trail, then UPDATEs via correlated subquery. Idempotent and portable between SQLite and Postgres. |
||
|
|
4851f54595 |
feat(file-manager): split "All Files" into internal-only + External views (#1621)
Reporter linked a NAS and the auto-imported files drowned their own Bambuddy uploads in the "All Files" sidebar listing. There was no filter to escape it — only per-folder clicks. Restore the pre-external semantics: "All Files" now lists managed-storage files only. The combined across-every-external view moves to a new sibling sidebar entry, "External", that only appears when at least one external folder is linked. Backend: GET /api/v1/library/files gains internal_only and external_only query flags. Filter is on LibraryFile.is_external. Both flags set is a 400, not a silent pick-one. Frontend: new topLevelView state on FileManagerPage (default internal); the query passes the scope only when selectedFolderId is null. Mobile selector dropdown uses __top:internal / __top:external sentinels so the same state round-trips through option values. Empty-state copy distinguishes internal-empty from external-empty. |
||
|
|
9554ebd05d |
refactor(inventory): rename /reset-usage to /reset-consumed-counter to match what it actually does (issue #1644)
The old endpoint name implied that calling it would drop weight_used to
0. In practice it only stamps weight_used_baseline = weight_used so the
Inventory page's "Total Consumed" widget (weight_used - baseline) reads
0 going forward, while remaining (label_weight - weight_used) is
preserved. Calling the endpoint via curl and seeing weight_used
unchanged in the JSON response is confusing.
New paths:
- internal: /api/v1/inventory/spools/{id}/reset-consumed-counter
/api/v1/inventory/spools/reset-consumed-counter-bulk
- spoolman: /api/v1/spoolman/inventory/spools/{id}/reset-consumed-counter
/api/v1/spoolman/inventory/spools/reset-consumed-counter-bulk
Behaviour is unchanged in both modes; internal stamps the baseline
directly, Spoolman-mode PATCHes upstream used_weight=0 and the
_map_spoolman_spool read mapping reconstructs the same "displayed
consumed = 0, remaining unchanged" Bambuddy-visible shape. Parity
between modes was already in place and is preserved.
The Spoolman-client method reset_spool_usage keeps its name because it
describes what is sent upstream to Spoolman, not what Bambuddy's
endpoint promises to callers.
Frontend:
- api.resetSpoolUsage / bulkResetSpoolUsage (and Spoolman variants)
renamed to resetSpoolConsumedCounter / bulkResetSpoolConsumedCounter.
- Button labels: "Reset usage to 0" -> "Reset counter" / "Reset all
counters" (short, unambiguous); tooltips and confirm-modal bodies
still spell out the full semantics.
|
||
|
|
673001e3cd |
fix(maintenance): #1596 persist wiki_url on Custom Type create + correct
inline mutation type
POST /api/v1/maintenance/types hard-coded the MaintenanceType
constructor and silently dropped `wiki_url`, so the Documentation URL
field disappeared after save. PATCH worked because it uses
`data.model_dump(exclude_unset=True) + setattr`, which is why editing
a freshly-created type DID save the URL — masking the bug under any
"save then immediately fix it" retest. Reporter @BurntOutHylian
pre-triaged the issue to the exact constructor call at
routes/maintenance.py:206-213; fix is the missing `wiki_url=data.wiki_url`
argument.
Frontend nit from the same report: MaintenancePage.tsx:1131's
`updateTypeMutation` declared `data: Partial<{ name; default_interval_hours;
interval_type; icon }>` — omitting `wiki_url`. The value reached the
API correctly at runtime because `api.updateMaintenanceType` accepts
`Partial<MaintenanceTypeCreate>` (which has wiki_url), but the inline
type lied about the payload shape. Extended the inline `Partial<{...}>`
to include `wiki_url?: string | null`. Pure type fix — no runtime change.
|
||
|
|
a837a3acbd |
● fix(library): #1600 thumbnail extraction for external-folder .gcode.3mf
files + unify file_type classification across ingest paths #1600: external-folder sliced outputs landed with no thumbnail. Cause: four backend ingest paths classified LibraryFile.file_type differently for the same .gcode.3mf family. upload / ZIP-extract / in-process used os.path.splitext()[1] which returns .3mf for foo.gcode.3mf and stored file_type="3mf", matching the thumbnail-extraction gate at library.py:1467 (file_type == "3mf"). External-folder scan explicitly detected the compound and stored file_type="gcode.3mf" — preserving "sliced output" identity — but then skipped both the "3mf" gate and the "gcode" gate, so the file landed with thumbnail_path = None. Same compound-extension drift that bit #1543 in the 3D preview, in a surface that audit didn't trace back to. Unified fix: - New classify_file_type(filename) helper in routes/library.py is the single source of truth. Returns "gcode.3mf" for sliced outputs and ext[1:] otherwise. - Applied to every ingest path: upload (line 1704), ZIP-extract (1998), external-folder scan (the bug site — the manual compound check is replaced), and in-process save_3mf_from_bytes (471, used by MakerWorld import). - External-scan thumbnail gate widened to `if file_type in ("3mf", "gcode.3mf"):` — a .gcode.3mf IS a 3MF zip with Metadata/plate_1.png; ThreeMFParser doesn't care about the trailing extension. - gcode-download endpoint at GET /library/files/{id}/gcode had the same drift in reverse: gate was `elif file.file_type == "3mf":` so a row stored with file_type="gcode.3mf" (the external-scan path's pre-unification behaviour, and the canonical going forward) got rejected with HTTP 400. Widened to the same compound-aware tuple. One-shot DB migration in core/database.py::run_migrations backfills existing legacy rows: UPDATE library_files SET file_type = 'gcode.3mf' WHERE file_type = '3mf' AND LOWER(filename) LIKE '%.gcode.3mf' Idempotent (post-update rows no longer match the file_type='3mf' predicate, so re-runs at every boot are no-ops) and dialect-neutral (LOWER + LIKE are identical under SQLite and Postgres). Without the backfill, users would have a permanent split state: old uploads at '3mf', new uploads at 'gcode.3mf' — which would double-bucket sliced outputs in the dashboard stats query at line 4615 and show two entries in the file-manager filter dropdown for the same conceptual type. Frontend untouched. FileManagerPage.tsx and ProjectDetailPage.tsx already accept both '3mf' and 'gcode.3mf' per the #1543 fix. After the migration the DB only contains canonical values, so the legacy '3mf' branches in the frontend become dead code for sliced files — they stay as defence-in-depth in case any future ingest path I missed reverts to the legacy classifier. |
||
|
|
5d6d928b3f |
fix(virtual-printer): #1429 net.info[*].ip cache leak + mode wire-value rename
#1429 (reported by @TrickShotMLG02, confirmed by @Mape6 on a flat single-LAN that rules out subnet / mDNS-reflector theories): with the physical printer off the slicer's "Send" landed in Bambuddy's archive; once the printer powered on every subsequent "Send" went straight to the printer's SD card and bypassed Bambuddy. Bundle analysis: mape6-before showed clean FTP receive + archive lines, mape6-after had zero FTP attempts to Bambuddy once the printer was online. Cause: mqtt_bridge.py::_resolve_client encoded _target_ip_uint32_le / _vp_ip_uint32_le ONLY on client-identity change and early-returned on every refresh tick when the same client object was still bound. If target_client.ip_address was empty at first bind (DB row stale, or client constructed before SSDP refresh filled it in), the encoding stayed None, the net.info[*].ip rewrite block was skipped, the cache filled with the real printer IP, sticky-key preservation kept the poisoned net value alive across every subsequent incremental push, and the slicer followed the leaked IP. Only Bambuddy-restart-with-printer-off cleared it — the workaround both reporters independently arrived at. Same shape on multi-NIC printers (X1C, H2D Pro): the rewrite only matched entries whose ip equalled _target_ip_uint32_le, so a secondary interface IP Bambuddy never saw would leak through unchanged. Bridge fix: - _resolve_client calls a new _refresh_ip_encoding() on every refresh tick, even when client identity is unchanged; self-heals once ip_address becomes valid. - _refresh_ip_encoding() sweeps the existing _latest_print_state when encoding becomes valid for the first time. Without the sweep, sticky-key preservation keeps the pre-arm poisoned cache alive forever — incremental pushes that don't include net carry the bad value forward. - _rewrite_net_info_ips() rewrites EVERY non-zero net.info[].ip entry that doesn't already equal the VP IP, not just entries matching _target_ip_uint32_le. Multi-NIC printers stop leaking secondary interfaces. Zero-IP placeholders are left alone so "active interface" detection still works. - INFO logging on encoding arm/update and on cache sweep so future bundles directly answer "did the rewrite fire?". Mode wire-value rename (#1429 follow-up, separate confusion source): - Both reporters' support bundles showed mode: immediate while the UI said "Archive"; @TrickShotMLG02 quoted: "I have no idea why it says immediate in the support-info.json file. In the webui the printer is set to archive". UI button "Archive" had always saved immediate, and "Queue" had always saved print_queue. Canonical wire values are now archive / review / queue / proxy, matching the button labels 1:1. - New normalize_vp_mode() + VP_MODE_* constants in models/virtual_printer.py; manager.py normalises on construction so a legacy row read pre-migration still dispatches correctly. - core/database.py::run_migrations rewrites existing virtual_printers and settings rows; idempotent (re-runs are no-ops); identical SQL under SQLite and Postgres. - API routes accept both legacy and canonical on input, normalise before storage. GET /settings/virtual-printer normalises on read so the frontend's mode-button highlight works for stale legacy values. - Three frontend VP components (VirtualPrinterSettings, VirtualPrinterCard, VirtualPrinterAddDialog) switched click handlers and type aliases to canonical; each got its own normalizeMode() helper so a stale-cached settings payload still highlights the right button. Two pre-existing `printer.mode === 'queue' ? 'review'` legacy mappings in VirtualPrinterCard were the source of a test failure caught mid-implementation where the new canonical 'queue' was being mis-aliased back to 'review' and hiding the auto-dispatch + force-color-match toggles. mode handler is NOT the dispatch bug: manager.py::_archive_file (the handler for archive mode) doesn't dispatch to the physical printer. The "files end up on the printer's SD card" symptom was the IP-leak from the bridge cache. The mode rename is purely clarity / support- bundle accuracy. |
||
|
|
1e08c25a9f |
fix(stats): #1593 multi-plate parser + per-run project rollup + carry-over system totals + accuracy band
Two stacked causes under-reported multi-plate prints in the project
rollup and the archive card.
Root cause 1 - parser only read plate 1.
ThreeMFParser._parse_slice_info used root.find(".//plate") and pulled
prediction / weight from that one element. Any multi-plate file's
archive-level print_time_seconds / filament_used_grams reflected
plate 1 alone. The /plates endpoint already looped findall and was
correct, which is why the plate carousel showed the right numbers
while the archive card was wrong.
Fix: loop every <plate> and sum prediction + weight. Per-plate
concepts (plate_number, _plate_index, printable_objects) only set
when there's exactly one plate - for multi-plate exports the
archive represents all plates and a single index doesn't apply at
the file level. bed_type keeps the first plate's value as a
best-effort default. Malformed prediction / weight on individual
plates skip cleanly rather than poison the sum.
Root cause 2 - project rollup aggregated PrintArchive, not the
per-run log.
compute_project_stats and list_projects quick-stats summed
PrintArchive.print_time_seconds / filament_used_grams / cost /
energy_* WHERE project_id. A reprint reuses the source archive row
and writes a new PrintLogEntry, so 3 sequential runs collapsed to 1
archive - and that archive's numbers were already plate-1-only from
cause 1. The Archive Print Log path was already correct because it
drove off print_log_entries (archives.py:420 comment).
Fix: both compute_project_stats and the list_projects quick-stats
block inner-join print_log_entries -> print_archives WHERE
archives.project_id. total_archives becomes COUNT(PrintLogEntry.id),
failed_prints counts runs in failed/aborted/cancelled/stopped,
completed_items is SUM(PrintArchive.quantity) for runs where
status='completed', time/filament/cost/energy from PrintLogEntry.
Orphan log rows (archive_id IS NULL post archive deletion) are
excluded by the inner join.
Same-shape fixes carried forward (no follow-ups per project rule):
system.py system-info totals: total_print_time / total_filament
had the same bug shape - summed PrintArchive directly so reprints
collapsed to one row. Now sums PrintLogEntry.duration_seconds /
filament_used_grams. The semantic shift is also a correctness
improvement: the field now reflects time the printer actually spent
printing, not slicer-estimated time.
archives.py time-accuracy metric: estimate / actual per run where
estimate = PrintArchive.print_time_seconds. Post-parser-fix
multi-plate archives have file-level estimate but per-run actual =
one plate, so ratio = N x 100% for an N-plate file. The calc now
clamps each row to the [50%, 200%] plausibility band before
contributing to the printer-level average; single-plate accuracy
(the case the metric is designed for) stays fully included.
Backfill: users with AMS spool tracking - the reporter's case - have
per-run filament_used_grams from the tracked spool delta, so stats
become correct immediately. Users without tracking fall back to the
archive estimate and undercount until they reprint. Archive card
still reads PrintArchive.filament_used_grams directly so old
multi-plate archives keep plate-1-only numbers until reslice -
forward-only as the reporter accepted.
|
||
|
|
c9bc5eb4e9 |
fix(webhook): #1584 PrinterState dataclass attribute access in status / stop / cancel
webhook.py treated printer_manager.get_status() return as a dict and
called .get(...) on it. The return is a PrinterState dataclass
(backend/app/services/bambu_mqtt.py), so the call raised AttributeError
and Starlette surfaced it as a generic 500 for every printer with a
status row. Non-existent printers correctly returned 404 because the
early "Printer not found" branch fired before the crash.
Reporter's repro matched exactly: id 1 (existing printer) returned 500,
id 2 and id 3 (no row) returned 404. Verified end-to-end against a live
PG-backed instance with the reporter's key shape — same 500 before the
patch, 200 with the correct payload after.
8 crash sites across 3 routes:
- webhook_get_printer_status GET /printer/{id}/status 5 sites
- webhook_stop_print POST /printer/{id}/stop 2 sites
- webhook_cancel_print POST /printer/{id}/cancel 2 sites
Every status.get("X", default) replaced with status.X if status else
default. Pydantic response schema unchanged; PrinterState's dataclass
defaults cleanly cover the "registered but never connected" branch so
the status route now returns 200 with connected=false, state=null
rather than crashing.
|
||
|
|
396e9aa09e |
security: harden path-traversal class across routes + services; fifth CI backstop
Two attacker-controlled strings were being joined to library_dir with no
resolve + containment check in the project ZIP import endpoint:
- linked_folders[*].name from the request's project.json
- per-entry zf.namelist() paths from the ZIP itself
An absolute path in either field collapsed the join (Path("/lib") / "/etc"
becomes Path("/etc") because pathlib discards the left side when the right
is absolute) and the next write_bytes landed wherever the attacker chose.
Adjacent finding from the routes audit: GET /archives/{id}/photos/{filename}
had NO validation on filename and FileResponse-served arbitrary paths -
the DELETE counterpart at least gated on the photos membership check.
Adjacent finding from the services audit: ArchiveService.attach_timelapse
wrote archive_dir / filename where filename ultimately came from a printer's
FTP listing (compromised-printer threat model) or the /timelapse/select
query param. A malicious printer that exposes a directory entry with ..
segments could write the timelapse outside the archive directory.
New backend/app/utils/safe_path.py::safe_join_under(parent, *parts) is the
single source of truth: rejects empty / null-byte / absolute parts up-front,
joins under parent, resolves both sides, asserts is_relative_to. Returns the
resolved canonical path on success, raises HTTPException(400) on escape, or
PathTraversalError when http=False (for service-layer callers that need to
match a non-HTTP return contract).
Wired into the import vectors, both archive photo handlers, and the
attach_timelapse service. The full audit sweep inspected every Path/Name
join in backend/app/api/routes/ AND backend/app/services/ - 25 route-layer
sites + 8 service-layer sites confirmed safe and tagged with
# SEC-PATH-OK: <reason> so future audits trust the inline guard at a glance.
Fifth CI backstop test_route_path_arithmetic_is_safe_joined_or_marked
AST-walks both layers and fails the build on any <dir-like>/<bare variable>
join that doesn't either route through safe_join_under or carry the marker.
The services layer is in scope because it receives values verbatim from the
routes AND from external sources Bambuddy has no control over (the printer
FTP-listing case above).
SECURITY.md gets a fifth rule + a fifth row in the CI test mapping table;
the rule now names the printer FTP-listing case explicitly so future
services-layer audits set the right expectation.
--------------
fix(library): suppress warning storm when bulk-uploading ZIPs of empty/stub STL files
Uploading a ZIP of stub or empty STL files (e.g. the 24-byte
"solid test\nendsolid test" shape) produced one WARNING per file in
stl_thumbnail.py::generate_stl_thumbnail. The warnings were technically
correct - trimesh returns a valid Mesh with zero vertices, the safeguard
matches, and the function returns None so the library entry is still
created without a thumbnail - but the volume turned a successful upload
into thousands of WARNING lines in the journal.
Two changes:
1. The per-file "Failed to load STL or empty mesh" message in
stl_thumbnail.py is now logger.debug instead of logger.warning. It's
a per-file content observation, not an actionable error; the caller
already handles None correctly. The branch now catches the rare
"large enough but trimesh still can't parse it" case, visible in
debug logs without spamming production.
2. New module constant MIN_USABLE_STL_BYTES = 200 (smallest binary STL
with one triangle is 134B, smallest ASCII ~150B; 200 is a safe floor
below any real STL). The three thumbnail call sites in library.py
(extract_zip_file, single-file upload, _backfill_external_stl_thumbnails)
pre-skip files below this size before calling generate_stl_thumbnail.
Stubs never enter the trimesh pipeline at all.
Behavior is unchanged for real STLs: any file >=200 bytes runs through
the existing pipeline, MAX_VERTICES still triggers simplification at
100k vertices for the 256x256 thumbnail render, large files still get
thumbnails.
------------
fix(stl-thumbnail): silence matplotlib first-import noise (writable cache + font_manager log level)
On first STL upload, three matplotlib-internal log lines surfaced:
WARNING [matplotlib] /opt/claude/.config/matplotlib is not a writable directory
INFO [matplotlib.font_manager] Failed to extract font properties from NotoColorEmoji.ttf
INFO [matplotlib.font_manager] generated new fontManager
The writable-dir warning fired because Bambuddy's $HOME isn't writable for
matplotlib's default config path; matplotlib fell back to /tmp/matplotlib-XXX
which lost the font cache on every host reboot, so font_manager rebuilt it
each cold start - producing another batch of INFO lines.
Fix is two small additions in stl_thumbnail.py before the matplotlib import:
1. New _configure_matplotlib_cache() sets MPLCONFIGDIR to
settings.base_dir/.cache/matplotlib (mkdir if missing) so the cache
persists across container restarts and the writable-dir warning never
fires. Respects an externally-set MPLCONFIGDIR so operators who chose
their own path aren't overridden. Best-effort with a debug fallback if
settings can't be imported or the mkdir fails.
2. logging.getLogger("matplotlib.font_manager").setLevel(WARNING) at module
import demotes the per-font INFO scan that fires when font_manager
builds its cache cold. Real font warnings (>= WARNING) still surface.
3 new tests: font_manager logger at WARNING after module import;
_configure_matplotlib_cache creates the directory under base_dir and sets
MPLCONFIGDIR; an externally-set MPLCONFIGDIR is preserved verbatim.
5516 backend tests green, frontend gates clean.
|
||
|
|
be15a375a6 |
fix(oidc): #1569 populate User.email from standard 'email' claim when email_claim is preferred_username
When an operator configures `Email Claim = preferred_username` (e.g. Authentik) the primary `_resolve_provider_email` correctly rejects the identity value as non-email shaped and returns None, leaving auto-provisioned users with `email=None` even though the same token carries a valid standard `email` claim. Add a narrow fallback in the auto-create-users branch only: when `provider.email_claim != "email"` and the primary returned None, resolve the standard `email` claim with the same Fall A/B shape + email_verified enforcement and use it for `User.email` and `UserOIDCLink.provider_email`. The auto-link-existing-accounts gate is left on the primary `provider_email`, so the GHSA Fall-B / Fall-C guards remain intact - the fallback never feeds account matching. |
||
|
|
b7d7c82501 | fix(security): WebSocket auth gate + audit-driven hardening sweep | ||
|
|
ec51394196 |
fix(security): GHSA-r2qv-8222-hqg3 — allowlist API-key permissions (CVSS 9.9)
API-key permission gates went from a 17-entry admin denylist with the three
documented scope flags (can_read_status / can_queue / can_control_printer)
enforced only inside /api/v1/webhook/* to an explicit per-Permission
allowlist consulted by every dependency:
- core/auth.py: _APIKEY_SCOPE_BY_PERMISSION maps every non-admin
Permission to one scope flag on APIKey; unmapped = 403.
_check_apikey_permissions now takes the api_key and checks the flag.
- require_any_permission_if_auth_enabled + require_ownership_permission
were returning None for any valid key with zero scope check; both now
invoke _check_apikey_permissions and fail closed.
- Two new scope flags on api_keys: can_manage_library (LIBRARY_UPLOAD /
UPDATE_OWN / DELETE_OWN / MAKERWORLD_IMPORT) and can_manage_inventory
(INVENTORY_CREATE / UPDATE / DELETE / FORECAST_WRITE — required by
SpoolBuddy kiosks). Default TRUE, backfilled from can_queue so existing
"queue-only" keys keep working and hardened "read-only" keys do not
silently gain writes.
- CLOUD_AUTH now routed through can_access_cloud for defence-in-depth
alongside the existing _cloud_api_key_gate.
- Migration column-existence check (_api_keys_column_exists) gates the
backfill so user-edited values are never overwritten on restart.
Structural drift backstop: test_every_permission_has_a_classification fails
CI on any new Permission added without an explicit scope mapping —
prevents the denylist-shape regression that grew the prior surface.
Backend 5469 tests green; ruff clean. Frontend build green; i18n parity
green across 9 locales (5005 leaves each, +6 new keys). Wiki permissions
table + allowlist callout + upgrade notes updated.
|
||
|
|
e10462678f |
fix(security): GHSA-6mf4-q26m-47pv — fail-closed on auth-probe DB errors (CVSS 9.8)
is_auth_enabled() and auth_middleware both caught every exception during
the auth-state probe and returned the "allow" answer instead of denying
the request. Reporter's PoC floods /api/v1/auth/login to exhaust file
descriptors, forcing the next SQLite connect to raise, then hits a
protected endpoint during the fail-open window with no token — granting
unauthenticated access to admin-account creation, API-key creation, DB
backup download, and printer control. CWE-636 / CWE-755. Affects >= 0.1.6.
Fix:
- is_auth_enabled (backend/app/core/auth.py): only returns False for the
legitimate "settings row absent" case; any actual exception propagates
so the caller can deny the request.
- auth_middleware (backend/app/main.py): returns 503 on any probe failure
instead of await call_next(request).
4 new regression tests in test_auth_fail_closed.py pin the contract
(propagates DB exceptions, returns False for no-row, True for "true",
False for "false"). 1 existing security test renamed and updated to
accept either 500 or 503 (both fail-closed) and to verify the
SQLAlchemy detail does not leak in the body.
Codebase grep confirmed no other auth-decision predicate has the same
fail-open shape: _validate_api_key returns None on catch (→ 401 fail-
closed downstream), is_advanced_auth_enabled propagates correctly,
permissions.py has no catch-alls.
Reported by @wondercrash via private advisory.
|
||
|
|
2241924312 |
fix(library): reject FAT32-illegal filename chars at rename/upload/queue time (#1540)
Bambu printer SD cards are FAT32/exFAT, which forbids < > : " / \ | ? * plus control chars and trailing dots/spaces. Library rename only blocked path separators, so a name like L|R.3mf was accepted and only failed later at FTP upload with 553 Could not create file - far from the rename action that caused it. Bambu Studio refuses these names in its save dialog; Bambuddy now does the same. New backend/app/utils/filename.py centralises validation. Wired into update_file, upload_file, print_library_file, and queue add. Existing rows with bad names are left alone (no silent rewrite of user data); users get an actionable 400 pointing at rename. Frontend rename modal mirrors the same set client-side with inline error. New fileManager.invalidFilenameChar i18n key translated across all 9 locales. 26 new tests in test_filename_validation.py. |
||
|
|
e9beb1e8fc |
fix(archives): handle fallback archives in source-3MF upload (#1531)
Archives created from prints Bambuddy didn't archive (cloud / Handy / SD-card prints) carry file_path="". The two source-upload routes computed the destination as (base_dir / archive.file_path).parent / "source", which collapsed to base_dir.parent / "source" for fallback rows — sending the file to /app/source/ (outside the data volume, orphaned on container restart) and raising 500 on the final relative_to. Centralise the destination math in _resolve_source_3mf_path. Normal archives keep the <archive>/source/<filename> layout. Fallback archives land at <base_dir>/archive/no_source/<id>/<filename>, which stays inside the data volume and is addressable by every existing read site. The helper also asserts the resolved directory is under base_dir.resolve() so a corrupted row fails with a clear message instead of writing outside the volume. Both upload routes (upload_source_3mf and upload_source_3mf_by_name) now route through the helper. Two regression tests in TestUploadSourceThreeMF pin both branches. |
||
|
|
4387a09162 |
fix(spoolbuddy): route weight sync by inventory mode exclusively (#1530)
POST /spoolbuddy/scale/update-spool-weight tried the local DB first and only fell back to Spoolman on a local miss. Combined with nfc/tag-scanned's post-#1119 always-Spoolman routing, a stale local Spool row sharing a numeric id with a Spoolman spool would absorb the sync silently while the Spoolman row stayed unchanged. Mirror the routing already used by nfc/tag-scanned: pick the branch via _get_spoolman_client_or_none() and never cross. Local mode now returns 404 on a local miss instead of falling through. New TestUpdateSpoolWeightSpoolman.test_stale_local_row_does_not_shadow_spoolman asserts both directions: Spoolman gets the update, the colliding local row's weight_used and last_scale_weight are untouched. |
||
|
|
c0c07bc509 |
fix(archives): cross-model re-slice no longer carries source printer_id
When the user re-sliced an H2D archive for X1C, the new archive card and
reprint modal both showed the source printer's name (e.g. "Workshop H2C")
even though sliced_for_model on the same row correctly read "X1C".
slice_and_persist_as_archive copied source_archive.printer_id verbatim
onto the new PrintArchive row. Both the archive card
(ArchivesPage.tsx:3571) and the reprint modal read printer_id first and
only fall back to sliced_for_model when it's None. With printer_id set
to the source's H2D printer, neither could see that the new file is for
a different model.
Same shape as the sliced_for_model fix already in the same function — a
cross-model re-slice means the source's physical printer isn't where
this output prints anymore. When the slicer-baked target model differs
from source_archive.sliced_for_model, drop printer_id so both surfaces
fall back to the sliced_for_model badge ("X1C"). Same-model re-slices
keep printer_id so the reprint modal still pre-selects the source
printer.
Edge case: when source_archive.sliced_for_model is None (older archives
that predate that column being populated), we can't tell whether this
is a cross-model re-slice. Fail open and preserve printer_id rather
than spuriously nulling it.
slice_and_persist (the library-file path) doesn't have this bug —
LibraryFile has no printer_id column.
Tests in TestSliceArchiveResliceModel cover all three branches: cross-
model nulls printer_id; same-model keeps it; unknown source model
preserves it.
Surfaced via the bambuddy demo doing H2D -> X1C re-slices after the
.bbscfg System-tier slicing fix landed.
|
||
|
|
4096d8d6bd |
fix(csp): nonce-based script-src so Cloudflare-injected scripts pass (#1460 follow-up)
Behind Cloudflare, the bot-detection script CF injects into every HTML response carries a hash that rotates per request, so it can never be allowlisted by hash. Reporters with CF in front had to relax their NPM CSP to 'unsafe-inline' as a workaround. Per Cloudflare's documented behaviour, when a nonce is present in the page's script-src, CF clones it onto its injected <script>. The SPA CSP now stamps a fresh per-request nonce via secrets.token_urlsafe(16), keeping 'self' for our own scripts (index.html has had no inline scripts since the SW registration moved to /sw-register.js in the original #1460 PR), so no HTML body rewriting is needed. Also folded in: /manifest.json, /sw.js and /sw-register.js now accept HEAD as well as GET, so `curl -I` and uptime scanners stop returning 405 on those routes - a separate red herring during this issue's debugging. Tests: 3 new in test_security_headers.py - 'nonce-' token stamped into SPA script-src while 'self' remains and 'unsafe-inline' does not; nonce is fresh per request across 5 sequential calls; HEAD on the three PWA routes never returns 405. 22/22 security-header tests green; backend ruff clean. |
||
|
|
4686d108ef |
feat(slice): cross-printer re-slicing across nozzle classes + multi-plate slice-all
Re-slicing a 3MF authored for a single-nozzle printer (X1C, P1S, A1, P2S)
onto a dual-nozzle printer (H2D / H2D Pro) — or vice versa — previously
failed with "G-code in unprintable area of multi-extruder printers" (the
source's bed-coordinate layout lands in the H2D's per-nozzle dead zone)
or, on multi-color projects, a hard SIGSEGV inside the slicer's ZFiller
polygon-clipping. Earlier shipped a fail-fast 400 guard; this drop lifts
it and actually does the conversion by forwarding the sidecar's existing
--arrange flag when the source and target nozzle classes differ. BS
itself reconciles the embedded project_settings.config against the new
printer that way, the same way the GUI's "Switch Printer" operation
does. The guard becomes a kept-for-compat no-op.
Slice-all-plates added to the SliceModal: a checkbox for multi-plate
sources sends plate=0 to the backend, which forwards --slice 0 to the
BS CLI. Same-class slice-all produces one multi-plate output 3MF in a
single sidecar call. Cross-class slice-all loops per plate (BS's
--arrange is project-wide and would otherwise consolidate every plate's
objects onto one bed) and merges the per-plate outputs into one
multi-plate 3MF locally via the new merge_plate_3mfs helper. The toast
shows "Plate 2 of 5 — Generating G-code (47%)" through the loop.
Three side fixes surfaced during testing:
- substitute_unused_plate_filaments overwrites unused-slot filaments
with the slot-1 selection before slicing so BS's loaded-filament
temperature validator doesn't reject a PLA print whose unused slot 2
defaulted to ABS in the dropdown
- re-sliced archive thumbnail now prefers the source's per-plate
render (Metadata/plate_N.png) over the project-wide MakerWorld cover
art, because BS CLI with --arrange skips writing a fresh per-plate
preview
- re-sliced archive bed_type now lifts from the sliced output's
curr_bed_type onto the PrintArchive column the card actually reads
Schema: SliceRequest.plate range relaxed from ge=1 to ge=0 to admit
the "all plates" sentinel; SlicerApiService.slice_with_profiles /
slice_with_bundle take an `arrange` parameter.
Tests: 26 in test_slicer_3mf_convert (count / merge / substitute /
extract), 3 in test_slicer_api (arrange wire format), 9 in
test_library_slice_api (guard no-op, bed_type lift, thumbnail
fallback, new cross-class slice-all loop integration test), 2 in
test_archive_service (Auxiliaries fallback), 4 in SliceModal.test
(plate=0 toggle), 2 in SliceJobTrackerContext.test (multi-plate toast
prefix). 659 backend + 42 frontend green; backend ruff clean,
frontend build clean, i18n parity green at 4984 keys × 9 locales.
|
||
|
|
7ea4410b21 |
fix(queue): insufficient-filament warning now fires on every dispatch path (#1496)
The pre-print deficit warning from #720 only ran inside the PrintModal submit flow. Both the green ▶ button on a staged queue row (POST /queue/{id}/start) and the Virtual Printer queue-mode intake bypassed it — auto_dispatch=True VP intakes would dispatch unsupervised onto spools that physically can't complete the print. Extracted the deficit check into backend/app/services/filament_deficit.py (single source of truth, both internal inventory and Spoolman modes). POST /queue/{id}/start returns 409 with a structured deficit payload unless ?skip_filament_check=true. The dispatch scheduler runs the same check before each _start_print; a deficit promotes the item to manual_start + sets a new filament_short flag (idempotent migration on print_queue). The flag clears automatically on the next tick when the operator swaps a spool to one with enough material. Frontend ▶ catches the 409 and opens a confirm modal showing each shorted slot's required vs remaining grams; the row now renders a yellow "Insufficient filament" badge when filament_short is set. Translated across all 9 locales. |
||
|
|
e222a0ef0e |
feat(system): log-health scanner + Add/Edit-Printer setup pre-flight
Adds a passive log-health check that complements the active Connection Diagnostic. Scans Bambuddy's recent app log against a curated allowlist catalog of known failure signatures (rejected access code, FTPS :990 timeout, FTPS TLS failure, flapping MQTT, unreachable camera, SQLite "database is locked" contention), dedupes and classifies each finding as layer8/environment/bug, and deep-links to the troubleshooting wiki. Sample log lines are sanitized before they leave the process. Exposed via GET /system/health and surfaced on two surfaces sharing one SystemHealthPanel component: a System Health section on the System page, and inline in the bug reporter when the form opens. The Add-Printer and Edit-Printer dialogs gained a setup-time pre-flight: saving runs the connection diagnostic and, on a failed check, warns with a "save anyway" escape hatch instead of silently saving a printer that will immediately show offline. Log read/parse/sanitize primitives extracted from routes/support.py into a shared services/log_reader.py (behaviour-preserving); affected support tests repointed accordingly. Tests: test_log_health.py (11), test_system_api.py (2 new), SystemHealthPanel + BugReportBubble + AddPrinterPreflight + EditPrinterPreflight (8 frontend). All strings translated across the 9 locales. Backend ruff clean, full unit suite green, frontend build + eslint clean, i18n parity green. |
||
|
|
6bc6a1d683 |
feat(virtual-printer): setup diagnostic + one-click slicer-certificate export
Two recurring virtual-printer support pains, both on the Virtual Printers
settings page.
Setup check: a stethoscope action on each VP card runs a pass/fail/warn/skip
checklist — VP enabled, services running, bind interface still exists, access
code set, target printer (proxy mode), and a live TCP probe of the FTP / MQTT
/ discovery ports on the bind IP. start_server swallows per-service bind
errors, so a service object can exist while nothing is listening; probing the
bind IP from outside is the only reliable signal and it catches the common
"VP not visible in the slicer" bind-IP-conflict and stale-interface cases.
Slicer certificate: virtual printers present a TLS cert signed by a shared CA
the slicer must trust. Until now users had to docker exec in and cat
bbl_ca.crt. A "Slicer certificate" row on the settings card now offers Copy
and Download (bambuddy-virtual-printer-ca.crt) plus the SHA-256 fingerprint.
GET /virtual-printers/ca-certificate returns only the public certificate; the
CA private key never leaves the backend. The CA is generated on demand so the
button works before the first VP is enabled.
Backend:
- services/virtual_printer/diagnostic.py — run_vp_diagnostic + port probes
- schemas/virtual_printer.py — VPDiagnosticResult
- CertificateService.get_ca_certificate_info() + manager helper
- routes: GET /virtual-printers/ca-certificate, /{vp_id}/diagnostic
Frontend:
- VirtualPrinterDiagnosticModal.tsx; stethoscope button on VirtualPrinterCard
- caCert row on VirtualPrinterList; utils/clipboard.ts (shared copy w/
non-secure-context fallback + downloadTextFile), de-duplicating the
existing FQDN-copy logic
- vpDiagnostic.* + virtualPrinter.caCert.* across all 9 locales
9 backend unit tests + 4 route integration tests + 6 frontend tests.
Backend ruff clean, frontend build clean, i18n parity green.
|
||
|
|
4925b4c830 |
fix(slice): re-slice correctness — model label, honest errors, filament usage, nozzle guard
Five follow-up fixes to cross-printer re-slicing, all surfaced while
testing archive re-slices.
1. Re-sliced archive now records the printer it was sliced FOR.
slice_and_persist_as_archive copied sliced_for_model from the source
archive, so re-slicing X1C->H2D still showed "X1C sliced". Read it
from the freshly-sliced 3MF's parsed metadata instead, falling back
to the source only when absent.
2. Real slicer rejections are surfaced instead of silently masked.
_run_slicer_with_fallback retried with the 3MF's embedded settings on
any sidecar 5xx — including genuine content rejections (object off
the bed, incompatible filament temps), which "succeeded" only by
re-slicing for the source's original printer. A new
_slicer_rejection_message detects the slicer's own error string and
surfaces it as a 400; the embedded-settings fallback is kept only for
true CLI crashes.
3. A failed slice opens an error modal, not a 3s toast. The slicer's
reason is actionable and a toast hides it before it can be read. New
AlertModal (acknowledge-only); SliceJobTrackerContext shows it on a
failed job. New slice.failedTitle key in all 9 locales.
4. Sliced files no longer report "0 g" filament usage. The sidecar
doesn't always populate the X-Filament-Used-* headers;
ThreeMFParser._parse_gcode_header now also reads the slicer's own
"total filament weight/length" from the G-code header, and both
slice-persist paths fall back to it when the sidecar reports 0.
5. Nozzle-class re-slice guard. Re-slicing across the single-nozzle <->
dual-nozzle boundary (e.g. X1C -> H2D) fails BambuStudio's
multi-extruder validation; both slice routes now reject it up front
with a clear 400. The dual-nozzle model classification — previously
an inline tuple duplicated across start_print and the K-profile
routes — is centralized into DUAL_NOZZLE_MODELS / is_dual_nozzle_model
in printer_models.py, consumed by all three sites and the guard.
Full cross-nozzle-class re-slicing (dual-nozzle project_settings
reconciliation) remains separately tracked.
Tests: _slicer_rejection_message, _canonical_printer_model,
guard_nozzle_class_reslice, is_dual_nozzle_model, the G-code-header
filament parse, AlertModal, and end-to-end slice-API coverage including
an X1C-archive-to-H2D 400. Backend ruff + i18n parity clean; frontend
build clean.
|
||
|
|
e1a236e408 |
fix(spoolman): decide spool assignability from the slot-assignment ledger, not extra.tag (#1122)
GET /spoolman/spools/unlinked hid any spool with a non-empty extra.tag from the AMS-slot assignment picker. extra.tag is only an RFID/NFC matching key -- OpenSpoolman writes its own NFC tag value into that same Spoolman field -- so every OpenSpoolman-tagged spool became un-assignable in Bambuddy even when it occupied no slot. get_unlinked_spools now determines assignability from the spoolman_slot_assignments table (the documented source of truth for slot assignments) and ignores extra.tag entirely. Both link_spool and the AMS auto-sync upsert a row there for every occupied slot, so the ledger is complete. get_linked_spools and find_spool_by_tag still use extra.tag -- they are genuine tag-match maps and are unaffected. Internal-inventory mode needs no parallel change: it stores tags in its own DB with no Spoolman extra collision. Updates test_get_unlinked_spools_success and adds test_get_unlinked_spools_excludes_slot_assigned. |
||
|
|
b06f8f6951 |
fix(spool-assignments): union both assignment tables in the missing-spool check + symmetric mode-switch clear (#1473)
notify_missing_spool_assignments_on_print_start queried only the legacy
SpoolAssignment table. In Spoolman mode that table is empty -- bindings
live in spoolman_slot_assignments -- so every used tray was flagged
missing, firing a false-positive notification on every print.
- spool_assignment_notifications.py: the assigned-tray set is now the
union of SpoolAssignment + SpoolmanSlotAssignment rows. Union-only,
so legacy-mode behavior cannot regress.
- settings.py: the Spoolman toggle cleared SpoolAssignment on switch-on
but never cleared SpoolmanSlotAssignment on switch-off. Added the
symmetric clear so stale Spoolman rows can't leak into a later
internal-mode session and mask a real missing-assignment warning.
Adds 3 notification tests + 1 mode-switch integration test. An audit
of the remaining SpoolAssignment consumers confirmed usage_tracker,
spool_tag_matcher and routes/inventory are correctly internal-mode-only.
|
||
|
|
d3f0e9ac73 |
fix(spoolman): per-print weight tracker falls back to local slot-assignment table for tag-less spools (#1459)
Reporter on Postgres + Spoolman saw weight never decremented after prints. Traced to _report_spool_usage_for_slots calling only client.find_spool_by_tag() — which returns None when extra.tag is empty. Non-RFID spools assigned via the Bambuddy UI intentionally leave extra.tag empty (per #1457 — we don't want fallback tags polluting Spoolman), so tag-less spools never got matched and weight tracking silently no-op'd. The tracker never consulted the local spoolman_slot_assignments table that has the binding. Adds _resolve_spool_id_via_slot_assignment() as stage 2 of the resolution chain. Stage 1 (existing tag-lookup) wins when present so RFID auto-sync remains unchanged. (ams_id, tray_id) derived from global_tray_id via the existing _global_tray_id_to_ams_slot helper, so external slots and AMS-HT slots resolve correctly. Threaded printer_id through the three callers (partial G-code, partial linear, final-usage report). Resolution path is logged ("via tag" vs "via slot-assignment") so support bundles confirm the fix is live. extra.tag is deliberately NOT auto-populated — that would re-introduce the exact pollution #1457 cleaned up. Slot-assignment table is the source of truth for non-RFID; extra.tag is reserved for hardware RFID. |
||
|
|
12b0c138f7 |
fix(spoolman): clear stale fallback-tag links on assign + link, prefer slot-assignment over tag-link in UI (#1457)
Reporter on a P1S with non-RFID spools saw an old, almost-empty spool in
the AMS hover card's "Spulen-ID" block while the "Zugewiesen" block
correctly showed the freshly assigned full spool. Two layers compounded:
(1) Non-RFID slots fall back to a deterministic per-slot tag
(hash(serial) + ams_id + tray_id). The Link / Assign routes wrote
that tag to Spoolman extra.tag but never cleared it from the
previous holder on re-binding.
(2) The frontend's hover-card resolver preferred the (stale) tag-link
over the user's explicit slot-assignment. Same precedence bug in
SpoolBuddy's fill-bar resolver and slot-action picker.
Frontend: swap precedence at 5 sites — slot-assignment outranks tag-link
everywhere. FilamentHoverCard's existing match-dedupe then collapses the
two "Open in Inventory" buttons back into one.
Backend: new _clear_stale_tag_links() in spoolman_inventory.py, called
from POST /spoolman/inventory/slot-assignments (with the slot's
deterministic fallback tag) and POST /spoolman/spools/{id}/link (with
the literal tag being bound — works for RFID and fallback). Best-effort:
Spoolman 5xx and per-spool patch failures log + continue, never wedge
the bind. get_fallback_spool_tag_for_slot promoted to a public helper
mirroring the frontend's signature exactly.
|
||
|
|
badf0bed04 |
Fix: Failure Analysis widget honours edited failure_reason / status (#1444)
PrintLogEntry.failure_reason is captured once at print-completion time
(main.py:3641) by copying archive.failure_reason — which is NULL while
the user hasn't classified the failure yet. The PATCH /archives/{id}
route then writes only to print_archives via a generic setattr loop,
so the log entry stays NULL and failure_analysis.py keeps grouping the
print as "Unknown". Same desync hits status — flipping it in the modal
never reached the entry either.
Mirror failure_reason and status from the PATCH payload to the latest
PrintLogEntry for that archive (highest id). Latest-only because
archive.failure_reason / status already reflect the latest run's outcome
(each reprint clears the archive value at main.py:2195 and rewrites it
at completion), so the Edit Archive modal is implicitly editing the
latest run — reprints of an archive that succeeded on the second attempt
keep the earlier failed run's original classification intact.
Scoped to those two fields only. cost / print_name / printer_id stay
unmirrored because per-run values legitimately diverge from archive
ones (partial-print cost on a failed run vs source archive's full-print
cost — see _compute_run_filament_grams at main.py:596).
|
||
|
|
12a352e5b8 | Merge branch 'main' into dev | ||
|
|
1677efb2c6 |
fix(labels): replace incorrect ams_30x15 preset with correct AMS holder sizes (#1426)
Reporter — the same person who originally requested the labels feature in #809 — discovered that the ams_30x15 preset's 30x15 mm dimension didn't actually fit any variant of the MakerWorld AMS Filament Label Holder (model 752566) it advertised. Two new presets replace it: - ams_holder_74x33 (74 x 33 mm) matches the printable label STL bundled in the MakerWorld project - ams_holder_75x55 (75 x 55 mm) fits the cardstock-insert variant the reporter validated on bench Both cross the 20 mm height threshold so they land in the roomy layout branch — swatch on the left, QR on the right, multi-line text (brand, material, hex code, spool ID) in the middle. The old 30x15 mm preset couldn't fit a QR code; the new ones do. No DB migration: the preset name was never persisted. Callers scripting the old ams_30x15 value get a clean 422 at the route's Literal validator with the new valid values listed. i18n: replaced inventory.labels.templates.ams.{label,hint} with amsHolderSmall and amsHolderLarge across all 8 locales with real translations; parity guard cleaned of the stale English-fallback cognate entries. Parity holds at 4856 leaves per locale. Tests: backend label renderer + integration tests cover both new presets; LabelTemplatePickerModal test updated for the 6-button grid and the new template value in the API-call assertion. |
||
|
|
03e3f5313e |
fix(#1420): negative-cache cover 404s, add GitHub rate-limit backoff
Cover endpoint had no negative cache: when every FTP path returned 550 for a print whose 3MF wasn't on the printer (typical SD-card print), each frontend refresh re-ran the full 8-path fan-out. Add _cover_404_cache keyed by (subtask_name, view_key) and short-circuit to 404 on hit; clear alongside _cover_cache on print start. Only populated on genuine 404 paths, not transient FTP errors, so flaky network doesn't lock out future retries. GitHub update-check had no backoff on 403 rate-limit. Add module- level _github_rate_limit_until plus three helpers; check before every api.github.com call in /updates/check and _discover_target_release. Read X-RateLimit-Reset from the 403 with a 1-hour fallback when the header is absent and a 60-second floor to guard against container/GitHub clock skew. Route surfaces retry_after_seconds so the UI can display real wait time. The "ffmpeg didn't terminate gracefully" line the reporter quoted is the standard SIGTERM/SIGKILL pattern in camera.py and unrelated to the FTP loop; it goes away on its own once the cover endpoint stops hammering the printer. |
||
|
|
fc32b388de |
fix(stats): align Filament Used / By Time / Success Rate with Total Consumed and Total Prints (#1390 follow-up)
Three independent root causes behind the divergences the reporter flagged after the archived-spool fix shipped — fixed together. (1) Filament Used vs Total Consumed. _compute_run_filament_grams returned the slicer estimate for completed prints even when inventory had measured the actual AMS weight delta. That made Stats and Inventory two different sources of truth: Stats showed slicer-estimate grams, Inventory showed AMS-tracked grams, and the two never agreed. Reordered the helper so the tracked spool delta (same source that drives weight_used behind Total Consumed) takes priority for every status. Slicer estimate stays as the fallback when no inventory was tracked; partial-progress scale stays as the fallback for failed/cancelled with no tracker. The _run_cost block right next to it was already tracker-first; only filament_used_grams was inconsistent. (2) Printer Stats By Time vs Quick Stats Print Time. /archives/slim only set actual_time_seconds when status == "completed". For failed/cancelled rows the frontend fell back to print_time_seconds (the slicer's full-print estimate — wrong number for a print that failed at 15%). Quick Stats already summed elapsed duration across all statuses, so the two halves of the page disagreed by the (estimate - actual-elapsed) gap on every non-completed event. Dropped the completed-only gate; failed/cancelled now report measured elapsed. (3) Success Rate %. Was successful / (successful + failed), excluding cancelled / stopped from the denominator. With "Total Prints: N" displayed right above the gauge that produced confusing numbers — 4 successful, 0 failed, 48 cancelled showed 100% out of an apparent 52 prints. Switched to successful / total_prints — matches the count the user reads from the widget header. |
||
|
|
3b552094a7 |
fix(spoolman): edit-spool patches the linked filament in place when singleton (#1357 follow-up)
Editing a Spoolman spool used to mint a brand-new filament every time
a match-key field (subtype/material/brand/color_hex) changed, orphan
the previous one, and re-link the spool. The reporter ended up with
dozens of duplicate "Amazon Basics / PLA Glow" filament rows.
PATCH /spoolman/inventory/spools/{id} now:
- Reuses the current filament_id when no filament-shaping field
changed (a note/weight_used edit never touches the catalogue).
- PATCHes the existing filament in place when it's a singleton
(only this spool points at it, archived spools included).
- Falls back to find_or_create_filament only when the filament is
genuinely shared with another spool.
Mirrors internal-inventory behaviour where editing a spool updates
the thing the spool points at instead of proliferating new entities.
|
||
|
|
134847a3bd |
feat(camera): in-app diagnostic for "Connection lost" (#1395 follow-up)
Step 2 of the camera architecture overhaul agreed after #1395. When the camera viewer hits its error state OR before a print at any time, a Diagnose button runs a staged check against the printer and renders the result inline: which stage failed, how long it took, and a translated remediation hint. Cuts off the "user opens a 'camera broken' ticket → ask for support bundle → triage" loop at the user's screen. Backend - New `backend/app/services/camera_diagnose.py` orchestrator with CameraDiagnoseResult / CameraDiagnoseStage dataclasses. - New POST /printers/{id}/camera/diagnose route in camera.py. - Stages: tcp_reachable — TCP socket open to 322 (RTSP) / 6000 (chamber) with 3 s timeout. Distinguishes timeout, refused, and host- unreachable into distinct summary codes so the frontend can show a precise remediation (firewall vs LAN-only off vs wrong IP). first_frame — captures one JPEG end-to-end via the existing capture_camera_frame_bytes pipeline. Auth + RTSP handshake + first keyframe collapse into one stage; the user-facing answer is the same regardless of which sub-layer failed. - Live-stream shortcut: when a viewer is currently watching the camera with a buffered frame < 10 s old, the diagnostic skips the real test and returns live_stream_active_healthy. Opening a fresh socket would kick the live viewer off on single-camera- connection firmwares (the #1348 reconnect-storm trigger), so we trust the real-world evidence instead. - Response surfaces protocol, port, and profile name for support triage — lets us ask "what does your modal say?" instead of "send the support bundle". Frontend - New CameraDiagnoseModal renders one row per stage with green- check / red-X / grey-skipped icons, the per-stage duration in ms, a remediation banner styled by overall status, and a Run again button. - Two entry points: 1. The viewer's error overlay grows a Diagnose button next to Retry. Retry stays the primary action; Diagnose is the escape hatch for users who can't see what's wrong. 2. A stethoscope icon in the viewer's always-visible control bar, between Refresh and Fullscreen. Pre-flight testing ("did my firmware update break the camera?", "is the camera up before I send a print?") doesn't require waiting for the stream to fail first. - Also lifted the previously-hard-coded "Camera unavailable" / "Retry" strings into camera.unavailable / camera.retry so the error UI is fully translated alongside the new keys. |
||
|
|
173edd9b7c |
● fix(vp-queue): inherit slicer print options instead of always using defaults (#1403)
Reporter sliced in OrcaSlicer with timelapse on, sent the job to a VP queue, started from the queue, and got no timelapse video. Their dispatch chain itself was correct (queue item -> scheduler -> MQTT command honors `timelapse`); the gap was at queue-add time. The VP's `_add_to_print_queue` reads `default_timelapse` (and the four other print-option settings) from the workflow settings card. That was introduced in #1235 to stop column-level defaults from winning. But it also discarded the slicer's actual choice carried on the MQTT `project_file` command, which all the slicers (Studio / Handy / Orca) ship as `timelapse: true|1`. Result: a user with the new-install value `default_timelapse=false` had to either flip the global setting or edit every queue item by hand, even though their slicer's "Print options" UI clearly said "record timelapse". Investigation went wider than #1403 because Martin's hypothesis was "the print options modal isn't respected either." Cross-checking 86 captured P1S `project_file` commands across the support packages shows 46 from the queue scheduler and 33 from background_dispatch emitting `"timelapse": true` correctly to real printers - the modal + re-print path is intact end-to-end. The slicer-side gap was the only real bug. Two unrelated dead-code issues turned up in the same dig and are folded in below. Fix (VP queue inheritance) - `on_print_command` in the VP manager now stashes the slicer's project_file dict keyed by filename, then signals an asyncio.Event. - `_add_to_print_queue` checks the dict first; if empty, creates the event and waits up to 2 s for it before reading the settings fallback. Each option flows through per-field - slicer value wins if present, else the existing settings default (so users who explicitly set `default_timelapse=true` in their VP workflow card still get that on slicers that don't send a print command). - MQTT field naming preserved exactly: `bed_leveling` (single L) on the wire stays mapped to `bed_levelling` (double L) on the Bambuddy column. Integer 0/1 from H-family slicers and bool true/false from P1/X1 slicers both coerce via `bool()`. - Capture is gated on `mode == "print_queue"` so immediate / review / proxy modes keep their pre-fix no-op `on_print_command` and don't accumulate stashed entries over the VP's uptime. - Wait is also skipped when there's no MQTT server attached (`self._mqtt is None`), so unit tests that invoke `_add_to_print_queue` directly don't pay the 2 s tax. - Capture is consumed on use so the dict stays bounded. - `printer_manager.get_status(...).get(...)` against a `PrinterState` dataclass that has no `.get()` method. - Every print option discarded (timelapse, bed_levelling, AMS mapping). The route 500'd before ever reaching the printer. Rewritten to mirror `POST /print-queue/{item_id}/start`: clear `manual_start=False` on the next pending queue item and let the scheduler dispatch with the queue's stored options intact. Response shape preserved. Side-bug b: vibration_cali default drift in background_dispatch - `ReprintRequest.vibration_cali` and `FilePrintRequest.vibration_cali` both default to `True` (matches Bambu Studio behavior for X1/P1). - Both `_process_job` call sites read `job.options.get("vibration_cali", False)`. Cosmetic today because the frontend always sends the field, but a latent landmine for any future caller that bypasses the schema. Both sites flipped to `True`. |
||
|
|
e61a454a0f |
fix(inventory): "Reset usage to 0" preserves remaining in both modes (#1390)
Reporter saw a 544 g spool jump to 1000 g after pressing the eraser.
"Spools and remaining weights are not changed" - the dialog promised
this; the implementation did the opposite. Root cause was an
architectural conflation: `weight_used` did double duty as the
resettable "consumed since tracking started" counter AND as the basis
for the displayed remaining (`label_weight - weight_used`), so zeroing
it correctly cleared the stat but unavoidably reset remaining to full.
Spoolman has separate `used_weight` and `remaining_weight` fields, so
the API call there was correct - but Bambuddy's frontend was also
computing remaining as `label_weight - weight_used` for Spoolman
spools (ignoring Spoolman's real `remaining_weight` field), so the
same visual bug bit there too. Inventory-mode parity required fixing
both halves in one drop.
Internal mode
- New `weight_used_baseline` column (Float DEFAULT 0) on `spool`.
- Reset stamps `baseline = weight_used` and leaves `weight_used` alone.
- Displayed consumed = `weight_used - baseline`; remaining =
`label_weight - weight_used` (unchanged).
- Subsequent prints continue to grow `weight_used`, so the resettable
counter naturally tracks post-reset delta and remaining keeps
decrementing across the reset.
Spoolman mode
- `_map_spoolman_spool` now reads Spoolman's `remaining_weight` field
and returns a synthetic `weight_used = label - remaining` so the
frontend's remaining calc matches Spoolman's real stored value;
`weight_used_baseline = synthetic - real_used_weight` so the consumed
counter (`weight_used - baseline`) matches Spoolman's `used_weight`.
- Fallback path (no `remaining_weight` set) preserves the old behavior.
- Related fix: `update_spool` (Spoolman PATCH) was deriving the default
`weight_used` from `used_weight`, so editing unrelated fields AFTER
a reset would patch Spoolman with `remaining_weight = label - 0 =
label`, trampling the real value. Now derives from
`remaining_weight` so non-weight edits preserve physical state.
Frontend
- `InventoryPage` `totalConsumed` aggregate switched to
`Math.max(0, weight_used - (weight_used_baseline ?? 0))`.
- `ForecastPanel` `computeDeltaRate`, `totalUsedG`, and the per-spool
"consumed" table cell got the same treatment so forecast and
inventory aggregates stay coherent across a reset.
- `?? 0` keeps pre-migration installs rendering correctly until
`init_db()` runs the idempotent ALTER TABLE.
Migration
- `ALTER TABLE spool ADD COLUMN weight_used_baseline REAL DEFAULT 0`
via `_safe_execute` - SQLite and Postgres both accept it; verified
end-to-end on Postgres 16.
|
||
|
|
b51598ea69 |
fix(printers): refuse to add a printer when the MQTT probe fails (#empty-card-reports)
Several support reports traced back to one root cause: a mistyped access
code in Add Printer left an empty card on the dashboard. POST /printers/
was persisting the row first, then firing connect_printer() fire-and-forget.
Now we test_connection() BEFORE the insert; failure returns HTTP 400 and
the row is never written.
Structured error response -- detail={"code", "message"} -- so the toast
shows the localized message instead of the English fallback. New
ApiError.code field on the frontend; printers.toast.connectionFailedNotAdded
|
||
|
|
48a7024b96 |
security(github-backup): refuse to save against a non-private repository
While auditing real-world Bambuddy backup repos on GitHub I found
several left public. That's a serious leak: the settings backup only
filters bambu_cloud_token and auth_secret_key, so mqtt_username,
mqtt_password, ha_token, prometheus_token, bambu_cloud_email,
external_url, and the printer access codes (via K-profiles) were going
to whatever visibility the user picked.
Hard guard at every save and re-checked on every push:
- POST /github-backup/config and PATCH /github-backup/config (when URL,
token, or provider changes) run a connection test internally and
return 400 unless is_private comes back True.
- run_backup() re-checks before each scheduled or manual push, so a
repository that flipped from private to public gets a clear
"Backup aborted: the target repository is no longer private" failure.
Each provider's test_connection now returns is_private (GitHub /
Gitea / Forgejo read data.private, GitLab reads visibility=="private";
"internal" is treated as non-private). None means "couldn't determine"
and is also rejected -- safer to fail closed.
Frontend renders visibility inline on Test Connection: green check when
private, red warning panel listing every credential at risk when public,
yellow when unknown.
---
ui(github-backup): show save-failure messages inline on the card
The new "repository is not private" rejection message is ~250 characters
listing every credential the backup carries (MQTT password, HA token,
Prometheus token, Bambu Cloud email, printer access codes), which clips
badly in a toast.
Both the initial-setup save and the debounced autosave now stash the
backend's error message into a saveError state and render it as a red
inline banner above the test-result block, with whitespace-pre-wrap so
the full message stays readable. The banner clears on success, on the
next save attempt, and when the user starts editing URL / token / provider
-- the three fields whose changes invalidate the privacy check -- so it
doesn't linger after the user has already addressed the cause.
Short success toasts (Settings saved, Token updated, Backup enabled) are
unchanged.
|
||
|
|
8b9efd0160 |
fix(inventory): "Reset usage to 0" works in Spoolman mode too (#1390)
First cut of this action only wired the built-in inventory path, so the
eraser buttons vanished when the user switched to Spoolman mode. Mirror
the endpoints on the Spoolman router:
- POST /spoolman/inventory/spools/{id}/reset-usage
- POST /spoolman/inventory/spools/reset-usage-bulk
Both route to a new SpoolmanClient.reset_spool_usage() helper that PATCHes
/spool/{id} with used_weight=0. The bulk variant keeps the same typo-wipe
guard (rejects empty/missing spool_ids), and individual Spoolman failures
are logged + counted out without aborting the batch.
InventoryPage mutations now switch on spoolmanMode to pick the right
client method, and the three "spoolmanMode ? undefined : ..." gates on
the eraser buttons are gone.
|
||
|
|
f645bd2bbe |
fix(stats): per-event data for all widgets, not just Quick Stats (#1390)
After #1378 moved Quick Stats to print_log_entries, six widgets and Failure Analysis still iterated the archive list. That made reprints multiply event-based widgets while leaving archive-based ones unchanged, and made hard-deleted archives drop from archive-based widgets while their orphan events kept feeding Quick Stats. Swap the data source in two places: - GET /archives/slim now reads PrintLogEntry, LEFT JOINs the archive for the sliced print_time_seconds estimate, prefers PrintLogEntry's own duration_seconds as the measured-time field. StatsPage is the only caller -- every widget realigns in one step. - FailureAnalysisService swapped from PrintArchive to PrintLogEntry for every aggregation. project_id filter still resolves through archives but counts matching events. Conftest archive_factory now syncs the synthesized event's created_at with the archive's so backdated test data survives the change. |
||
|
|
bff240e90a |
fix(uploads): pre-flight validation for 3MF/gcode + visible upload errors (#1401)
Reporter @iitazz uploaded slicer output to Bambuddy, clicked Print,
and the printer rejected every job with "Printing stopped because
the printer was unable to parse the 3mf file". Support bundle showed
the stored library file ended in .gcode (not .gcode.3mf), and
background_dispatch.py appends ".3mf" to filenames that don't
already end in .gcode.3mf/.3mf — so raw gcode shipped to the printer
named .gcode.3mf and the firmware's 3MF parser choked. Same shape
also surfaced as "File is not a zip file" on Bambuddy's own plate
parser.
New validate_print_file_upload() helper in library.py runs at upload
time:
- Reject filenames ending in .gcode (but not .gcode.3mf) with a
clear message — Bambu printers need .gcode.3mf zip containers,
not raw gcode.
- For .3mf / .gcode.3mf uploads, verify body starts with PK\x03\x04
(ZIP magic); reject otherwise pointing at the slicer's "Export
Plate Sliced File" action.
Applied to every relevant upload route: POST /library/files (covers
File Manager + printer-card drag-drop), POST /archives/upload,
POST /archives/upload-bulk (rejects per-row so one bad file doesn't
abort the batch), POST /archives/{id}/source, POST /archives/upload-source.
Runs after _resolve_upload_destination so folder-permission errors
(403 readonly, 400 missing-path, 409 collision) still take precedence.
STL / image / other non-print uploads bypass the validator.
FileUploadModal frontend fix: the modal auto-closed after every
batch regardless of per-file results, so a 400 rejection was captured
but invisible. Now:
- Errors render inline as red text under the file row instead of
as a hover-only title tooltip.
- Modal stays open if any file ended with status='error', so the
user can read the backend's remediation message before closing.
- Successful-only batches still auto-close as before.
UploadModal (bulk archive) was already showing inline errors and
not auto-closing — no change needed there.
|
||
|
|
e7045597bc |
fix(archives): bulk and auto purge now honour the soft / hard delete choice from #1343 (#1390 follow-up)
Reporter IndividualGhost1905 followed up after the #1378 / #1343 backfill landed and pointed at the next inconsistency: the per- archive delete dialog has had a "Also remove this print from Quick Stats" checkbox since #1343, but the "Purge Old" button and the scheduled daily auto-purge sweeper both ignored that choice and hard-deleted unconditionally. From the user side this looked like "automatically deleted from statistics without any warning" — half- true, and the inconsistency was real either way. The actual current shape (before this fix): - POST /archives/purge -> archive_purge_service.purge_older_than -> ArchiveService.delete_archive (hard). Archive row dropped. Linked PrintLogEntry rows have ON DELETE SET NULL so they survive as orphans with archive_id=NULL. Quick Stats keeps the filament / cost / energy contribution because the log rows are still there, but the archive-list-iterating widgets (Filament Trends, By Material, Color Distribution, Printer Stats) lose the row, and Time Accuracy loses its join target. Visibly inconsistent. - Scheduled _maybe_run_auto_purge -> same code path, same effect. - Single-archive DELETE /archives/{id} -> already takes purge_stats=true|false (default false=soft) and routes either soft_delete_archive (keeps everything, flips deleted_at) or deletes PrintLogEntry rows first + hard-deletes archive. The fix threads the same purge_stats flag through every bulk surface with soft as the default, matching the single-archive default: Backend: - archive_purge_service.purge_older_than(..., purge_stats=False) -> per-row soft_delete_archive when False, per-row PrintLogEntry deletion + delete_archive when True. Each runs in its own session (same pattern the sweeper already used). - preview_purge gains the same kwarg so the eligible-count matches what an actual run would touch: soft mode excludes already-soft-deleted rows, hard mode counts them as eligible for promotion. - get_settings / set_settings now persist archive_auto_purge_stats (default False). _maybe_run_auto_purge reads it on every tick. - ArchivePurgeRequest / ArchivePurgeResponse / ArchivePurgeSettings schemas extended. - Route /archives/purge accepts the body flag, /purge/preview accepts the query param, /purge/settings GET + PUT echo the setting. Frontend: - "Purge old archives" modal: new "Also remove from statistics" checkbox under the preview, unchecked by default. Plumbed into the preview query key + the execute mutation. - Settings -> Archives auto-purge card: matching toggle next to the days slider, disabled when auto-purge itself is off. - api.previewArchivePurge / api.executeArchivePurge accept the flag; ArchivePurgeSettings type gains purge_stats. - All 8 locales (en, de, fr, it, ja, pt-BR, zh-CN, zh-TW) get new purgeStatsLabel / purgeStatsHint / purgeStatsDescription keys, plus rewritten effect / warning copy in archivePurge and archiveAutoPurge to reflect that the default no longer "permanently removes from the database" but instead hides the row + removes files while keeping Quick Stats intact. i18n parity check clean: 4814 keys across all 8 locales, no fallback. Behaviour change for existing users on auto-purge: the sweeper used to hard-delete by default and now soft-deletes by default. After the upgrade those installs start *preserving* more data in Quick Stats rather than losing it — safer direction of the two, but worth the explicit call-out. Anyone who wants the old behaviour ticks the new toggle once and it persists. |
||
|
|
4a98914d4a |
fix(spoolman): persist Color Name via spool.extra — Spoolman has no filament.color_name field (#1357)
Reporter pgladel edited a spool's Color Name in Spoolman mode, hit Save, and saw the value snap back to the subtype on the next read. The earlier #1319 fix correctly handled the read/form-prefill half (the color_name_is_synthesized flag, blank-on-synth form init), but the write half assumed Spoolman has a `color_name` field on Filament. It doesn't. Verified against the live FilamentUpdateParameters schema on Spoolman 0.23.1 — the accepted fields are name, vendor_id, material, price, density, diameter, weight, spool_weight, article_number, comment, settings_extruder_temp, settings_bed_temp, color_hex, multi_color_hexes, multi_color_direction, external_id, extra. No color_name. Spoolman's PATCH happily returns 200 for {"color_name": "Red"} and silently discards the unknown key, so find_or_create_filament was either patching a void or creating filament after filament with the same field-that-doesn't-stick (which is what produced the "BB also created a bunch of new filaments" duplicate trail on each save attempt). The fix follows the same pattern as the existing BambuStudio slicer- preset storage: persist color_name on spool.extra.bambu_color_name as a JSON-encoded string, register the extra field via ensure_extra_field before write (Spoolman 400s on unknown extra keys), and read it back in _map_spoolman_spool with priority extra > filament.color_name (forward-compat for any future Spoolman release that adds the field) > subtype synth. Dropped the now-dead color_name passing through find_or_create_filament and create_filament — Spoolman would discard it anyway and keeping the dead pipe risked the same confusion the next time someone reads this code. The previous "match by name then patch color_name" loop is gone; what survives is the name-match resilience that lets an AMS-sync-created filament named "Glow" still match the user-driven edit's composed "PLA Glow", which prevents re-introducing the duplicate-filament trail. The frontend form's color_name_is_synthesized handling is unchanged — that part already worked. |
||
|
|
9c7e6a7b2c |
fix(updates): add --force to git fetch so re-pointed remote tags don't fail the update
In-app updater was failing on installs whose local clone had stale
tags (e.g. v0.2.1 re-pointed upstream after a post-release re-tag).
git fetch --prune --tags returns a non-zero exit when even one tag
would be clobbered, even though origin/main and the target release
tag itself fetched cleanly:
! [rejected] v0.2.1 -> v0.2.1 (would clobber existing tag)
...
ERROR Git fetch failed: From https://github.com/maziggy/bambuddy
The updater surfaced this as "Failed to fetch updates" and aborted,
leaving the user stuck on the previous release.
Adding --force lets the moved tag overwrite the local stale copy.
That matches the in-app updater's contract ("sync me to the remote")
and is what the native update.sh sidesteps entirely by not using
--tags at all. The in-app path can't drop --tags because release-tag
refs (v0.2.4b1, v0.2.4.1, etc.) need to be resolvable locally for the
subsequent git reset --hard.
Regression test asserts --force is in the fetch args alongside the
existing --tags assertion.
|
||
|
|
84ed28d5fa |
fix(queue): cancel pending items and hide stale archive surface when archive soft-deleted (#1348)
Opening the Print Queue page fired 404s on /archives/{id}/thumbnail,
/archives/{id}/plates, and /archives/{id}/plate-thumbnail/{n} for any
row pointing at a soft-deleted archive. Two underlying problems wearing
one mask:
1. Cosmetic: the queue API was copying item.archive.thumbnail_path into
archive_thumbnail without checking deleted_at. Soft-delete leaves the
row (so the relationship resolves) but removes the file from disk, so
the cached path was always stale.
2. Functional: a queue item whose 3MF was removed can never dispatch.
Without an explicit cancel, the item sits in 'pending' forever with
no indication to the user about why nothing is printing.
Fix in three parts:
- New _cancel_pending_queue_items() helper, called from soft_delete_archive
alongside the existing print-log thumbnail cleanup. Sets status='cancelled'
+ waiting_reason='Source archive deleted' on every pending queue item
linked to the archive. Only 'pending' is touched - completed/failed/
cancelled rows are historical and untouched. Hard-delete is already
covered by ON DELETE CASCADE on print_queue.archive_id.
- Queue API serializer now checks item.archive.deleted_at before
populating any archive-derived field. New archive_deleted: bool field
on PrintQueueItemResponse signals the soft-deleted state.
- Frontend's getArchivePlates query in QueuePage was gated on archive_id
only - archive_id is the real FK and stays exposed for dispatch/audit,
so added an explicit && !item.archive_deleted clause to respect the
new flag. Thumbnail render and CompactHistoryRow/QueueTimelineView
already gate on archive_thumbnail so the backend suppression alone
covers them.
Regression tests pin cancel-only-pending behavior, soft-deleted
suppression + archive_deleted=True flag, and the sanity guard that
live-archive fields keep flowing through unchanged.
|
||
|
|
cad63500a8 |
fix(archives): clear stale thumbnail paths on log entries when archive deleted
Archives → Print Log was 404-storming the thumbnail endpoint on every render: PrintLogEntry.thumbnail_path is copied by value from the archive at write-time, but the FK on archive_id is ON DELETE SET NULL (#1378) so log entries survive archive deletion to preserve stats history - and the cached path keeps pointing at a file that was removed when the archive's directory was deleted. Same shape for failed prints whose extractor never wrote the thumbnail. Two-part fix: 1. Route self-heals: get_print_log_thumbnail NULLs thumbnail_path on the entry and commits before returning 404 when the file is missing on disk. The frontend's <img> tag is gated on entry.thumbnail_path being truthy, so the next fetch of the log list skips the request entirely. 2. Eager clear on archive delete: new _null_print_log_thumbnail_paths() helper called from both soft_delete_archive and delete_archive before the on-disk files are removed. Avoids the one-time storm for future deletes; covers both the manual delete route and the auto-purge sweeper at archive_purge.py. Regression tests cover soft delete, hard delete via ArchiveService, and the route's lazy-NULL for failed-print orphans where the file was never written. |
||
|
|
5e88ce13f0 |
fix(notifications): accept discordapp.com webhook URLs (#1363)
Discord's "Copy Webhook URL" button emits discordapp.com URLs; both hostnames serve the same webhooks. The validation now accepts either prefix while keeping the check itself in place to catch the paste-the-wrong-thing error. |
||
|
|
b5a83924eb |
fix(cost): top-up untracked filament at default rate so multi-color
archives stop reporting near-zero cost (#1344)
Reporter @nicktags hit $0.01 on a 110.3g multi-color print with the
global default filament cost set to $10/kg. archive.py initial cost
calc was correct (~$1.10), then usage_tracker.on_print_complete
overwrote archive.cost with sum(r.cost for r in results) -- where
results only includes AMS trays mapped to a spool in Bambuddy's
inventory. On a multi-color print where 3 of 4 used trays had no
inventory spool, only the one tracked slot's tiny share (~1g) survived
and the archive recorded $0.01.
The overwrite logic dates to #505 (Feb 2026) and is correct for
fully-tracked single-color prints, but the multi-color slicer feature
in 0.2.4 (
|
||
|
|
29379e3be7 |
fix(camera): plate-detection UI now uses the external camera when configured (#1359)
Reporter @Andlar94 hit a permanent "Build plate not empty" on every print start on an A1 with an external RTSP camera. The runtime auto-check at main.py:1819 called check_plate_empty with use_external=external_camera_enabled, but the manual UI routes (camera.py) declared use_external: bool = False and the frontend client always sent use_external=false. So calibration captured a built-in frame and stamped it as the reference; the runtime check captured an external frame and diffed it against that reference -- a permanent mismatch. Centralise the default on the backend: both routes now take bool | None, deriving the default from the printer's external_camera_enabled + external_camera_url + external_camera_type. The frontend client stops sending the flag unless the caller explicitly sets it, so the existing UI call sites immediately benefit and any future caller gets the right camera automatically. Explicit overrides still win. Adds 4 regression tests pinning the new default for both the external-enabled and external-disabled cases, plus the explicit override path so a future "always built-in" caller stays supported. |