mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-02 20:22:15 +02:00
f385936c58c8d8e8eb019ca63fc0fd4e5b08f90e
648
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
3b9633a178 |
● feat(support): include sanitized connection / VP / log-health diagnostics in support bundle and bug report (#1506 follow-up)
The three diagnostic surfaces shipped earlier this month ( |
||
|
|
eae96da56e |
fix(camera): probe ffmpeg for the right RTSP socket-timeout flag (#1504)
A previous attempt swapped `-timeout` → `-stimeout` unconditionally to
fix EADDRINUSE on the reporter's transitional ffmpeg. That broke every
install on a modern ffmpeg (5+/6+/7+) — current Debian/Ubuntu/Homebrew
— where `-stimeout` was removed and `-timeout` is back to meaning
socket I/O. Verified locally: `ffmpeg -stimeout ...` errors
"Unrecognized option 'stimeout'" on ffmpeg 7.1.
install on a modern ffmpeg (5+/6+/7+) — current Debian/Ubuntu/Homebrew
— where `-stimeout` was removed and `-timeout` is back to meaning
socket I/O. Verified locally: `ffmpeg -stimeout ...` errors
"Unrecognized option 'stimeout'" on ffmpeg 7.1.
ffmpeg has shipped THREE arrangements of this option over time and
Bambuddy supports the full range:
- Pre-deprecation (early 4.x and earlier): `-timeout` is socket I/O.
- Transitional (~late-4.x, Jammy-era): `-timeout` is deprecated and
repurposed to RTSP listen-mode timeout; any non-zero value implies
`-listen`, which makes ffmpeg bind the TLS-proxy port and fail with
EADDRINUSE. `-stimeout` is the replacement socket I/O option.
- Modern (5.x / 6.x / 7.x): `-stimeout` REMOVED. `-timeout` is back to
socket I/O — the original meaning.
So no single literal is correct on all installs.
Fix: `rtsp_socket_timeout_flag()` in services/camera.py probes
`ffmpeg -h demuxer=rtsp` once and picks `-stimeout` when ffmpeg
advertises it (covers transitional + older builds that kept it as an
alias), else `-timeout` (modern + pre-deprecation). Cached at module
level for the process lifetime — ffmpeg doesn't swap mid-run.
The function returns the option name without a leading dash; callers
prepend it themselves so a formatting bug can't pass an empty flag.
Wired into both RTSP ffmpeg call sites in lockstep: routes/camera.py
(printer camera) and services/external_camera.py (external RTSP),
which use the same TLS-proxy + ffmpeg pattern and would hit the same
regression on either ffmpeg cohort.
Tests: 8 in test_ffmpeg_rtsp_timeout_flag.py — 6 probe unit tests
(prefers stimeout when advertised, falls back to timeout on modern,
defaults to timeout when ffmpeg missing or probe raises, caches across
calls, trailing-space substring guard against `-listen_timeout`
false-positives), 2 parametrised guards against either RTSP ffmpeg
argv re-hard-coding a literal instead of consuming the probe. 37
probe + existing external-camera tests green.
|
||
|
|
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.
|
||
|
|
3058c5789b |
fix(slice): @BBL name fallback for users without slicer bundles (#1325 follow-up)
The first cut of #1325 swapped a stale hardcoded model table for bundle-based compatibility matching: a cloud / standard process or filament preset was classified by consulting the user's uploaded Slicer Bundles (.bbscfg). That works perfectly when bundles cover every printer in the user's cloud catalogue, and silently no-ops otherwise - every cloud preset resolves to 'unknown', nothing moves into "Other printers", and the dropdowns look identical to the pre-fix state. The reporter saw exactly this on a clean install with no bundles uploaded. Restored BambuStudio's `@BBL <token>` name convention as a third tier below the bundle path, but driven by the canonical backend PRINTER_MODEL_MAP - exposed via a new GET /slicer/printer-models route - rather than a manually-maintained frontend table. The matcher inverts the registry into short-code -> printer-fragment ("X1C" -> "X1 Carbon"), normalises whitespace + case so "A1 mini" and "A1 Mini" compare equal, and falls back to raw-token compare for models not yet in the registry (so a future "Q1" matches without a code change). Adding a new Bambu model still touches exactly the one backend file already listed in the Bambu Model Codes registry. Tests: 2 new in test_slicer_presets.py (route returns the full PRINTER_MODEL_MAP, route returns a copy not the live dict); 11 new in slicerPrinterMatch.test.ts covering registry-driven X1C vs X1 Carbon, A1 vs A1 mini, H2D vs H2D Pro, P2S / H2C / H2S / X2D (which the original hardcoded list was missing), raw-token fallback, registry-not-loaded-yet degradation, and the precedence rules between compatible_printers / bundle / @BBL name. 38 slicer-presets + 36 slicerPrinterMatch + 34 SliceModal tests green; backend ruff clean; frontend build 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.
|
||
|
|
71e58e6cf1 |
fix(library): show the filename, not the embedded 3MF Title (#1489)
File Manager cards, search and sort keyed off file_metadata.print_name, which ThreeMFParser lifts from the 3MF's <metadata name="Title">. That title is the in-app project title — generic "Exported 3D Model" for any Bambu Studio "Save As", a marketing title for a MakerWorld download — and almost never the filename the user saved as. A card for Whatever.3mf showed "Exported 3D Model"; correcting it needed a rename round-trip, since the Rename dialog disables Save while the name is unchanged. The slicer-output write path already dropped print_name for this exact reason; the four other paths that store parsed 3MF metadata onto a LibraryFile did not — external-folder scan, managed multipart upload, the multi-file ZIP-upload branch, and MakerWorld import. Add a shared _without_print_name() helper and apply it at all four import paths; switch the slicer path to it so there is one rule. A LibraryFile's display name is its filename — only PrintArchive carries a real print_name, which is untouched. Remove the now-redundant filename->print_name mirroring in the rename route. Add a one-time idempotent data migration (_migrate_drop_library_print_name, SQLite json_remove / PostgreSQL jsonb key-removal branched on is_sqlite()) so libraries imported before the fix correct themselves without the rename workaround. No frontend change: print_name || filename yields the filename once print_name is gone. Tests: 6 new in test_library_print_name.py cover _without_print_name and the migration (incl. idempotency, siblings preserved, null metadata). SQLite migration branch verified by test; PostgreSQL branch verified against a real Postgres instance. |
||
|
|
056f06a396 |
fix(camera): capture ffmpeg stderr when an RTSP stream stalls (#1395)
A P2S support bundle on 0.2.5b1 — per-model probesize fix already applied — showed the camera still failing: ffmpeg connects, stays alive 30+ seconds, emits zero JPEG bytes, the 30s stdout.read times out, reconnect loop repeats. No ffmpeg stderr appeared anywhere in the log to explain why. The cause was a diagnostic bug, not the camera path. _read_ffmpeg_stderr called process.stderr.read() — read-to-EOF. A stalled-but-still-alive ffmpeg (the P2S RTSP failure mode) never closes stderr, so the read blocked until the 2s wait_for timeout and returned None, discarding the banner + stream-analysis lines ffmpeg had already printed. ffmpeg stderr was captured only when it fully exited; once the probesize bump turned the earlier crash into a hang, the diagnostic went dark. Drain stderr incrementally in bounded 8KB chunks (64KB cap), returning whatever ffmpeg printed so far whether or not it has exited. Also log the resolved per-model probesize/analyzeduration on the info-level "Starting RTSP camera stream" line, and log the full ffmpeg argv at debug level with only the credential-bearing camera URL redacted instead of hiding the entire command. No behaviour change to streaming — this makes the unresolved P2S RTSP stall diagnosable in the next support bundle. |
||
|
|
3286ccd7d2 |
fix(inventory): send honest Bambuddy User-Agent on FilamentColors.xyz sync
The Color Catalog sync built its httpx.AsyncClient with no User-Agent, so it leaked httpx's default python-httpx/x.y string - the only outbound client that did; bambu_cloud, makerworld and firmware_check all send Bambuddy/1.0 (+https://github.com/maziggy/bambuddy). It now sends the same honest UA. Found while investigating an issue - a Cloudflare 403 on the sync that turned out to be the reporter's network/IP reputation, not Bambuddy. The UA leak was a separate inconsistency found in passing; this change does not by itself resolve a Cloudflare IP block. |
||
|
|
e0247fc6a6 |
fix(slicer): filter process/filament presets by uploaded bundles, not preset names (#1325)
The process dropdown still mixed @BBL P2S presets into an X1C list: slicerPrinterMatch matched cloud/standard presets by parsing the @BBL <model> name suffix against a hardcoded model-code allow-list that was missing P2S, H2C and X2D — so those presets resolved to "unknown" and stayed in the main list instead of "Other printers". Drop both hardcoded model tables. Compatibility now comes from the user's uploaded Slicer Bundles: a bundle is scoped to one printer and lists the presets it ships, so a preset matches a printer exactly when some bundle for that printer contains it. New models are covered the moment their bundle is uploaded. |
||
|
|
e738645b0d |
feat(slicer): filter slice profiles by printer + default from the 3MF (issue #1325)
The Slice dialog listed every process / filament preset regardless of the chosen printer, and always defaulted to the first listed preset rather than what the 3MF was prepared with (#1325). Matching uses the slicer's own compatible_printers list for imported (local) presets and falls back to the "@BBL <model>" name suffix for cloud / standard presets. Compatibility-unknown presets are never hidden. Defaults: the printer and process dropdowns default to the preset names embedded in the source 3MF's project_settings.config when those presets are available; the per-slot filament and process pre-picks prefer a printer-compatible preset, and switching the printer re-picks any selection left incompatible. - UnifiedPreset gains compatible_printers, exposed for the local tier - plates endpoints return embedded_printer / embedded_process - new frontend util slicerPrinterMatch.ts; extract_embedded_presets_from_3mf - slice.otherPrinters added across all 9 locales |
||
|
|
7eba29624b |
feat(slicer): filter process & filament profiles by selected printer (issue #1325)
The Slice dialog listed every process / filament preset regardless of the chosen printer (#1325). Picking a printer profile now filters both dropdowns to compatible presets; presets resolving to a different Bambu model move into a trailing "Other printers" group. Matching uses the slicer's own compatible_printers list for imported (local) presets, and falls back to the "@BBL <model>" name suffix for cloud / standard presets where no compatibility metadata is available, so all three tiers are covered. Compatibility-unknown presets (custom or untagged) are never hidden. The pre-pick and printer-switch paths follow the same rule. - UnifiedPreset gains compatible_printers, exposed for the local tier - new frontend util slicerPrinterMatch.ts with the matching logic - slice.otherPrinters added across all 9 locales |
||
|
|
76e327f4a1 |
feat: connection diagnostic for "printer won't connect" triage
A triage review of the last 200 closed issues found ~1/3 were
user-side setup errors — printer not in LAN developer mode, blocked
ports, Docker bridge networking, wrong access code, cross-subnet —
each costing a multi-round-trip support exchange.
Add a Connection Diagnostic that runs those checks automatically:
- backend/app/services/printer_diagnostic.py: TCP probes of MQTT
8883 / FTPS 990 / RTSPS 322, LAN developer mode, Docker network
mode, printer/host subnet match, MQTT credential class; each
check returns pass/fail/warn/skip with a localized fix.
- Routes: GET /printers/{id}/diagnostic (saved printer) and
POST /printers/diagnostic (pre-save Add-Printer flow).
- ConnectionDiagnostic.tsx: modal + shared checklist, surfaced from
the printer card actions menu, an offline-printer quick button,
the Add-Printer dialog, and a new System-page section.
- The in-app bug reporter scans configured printers when the form
opens and always shows the result inline — a healthy confirmation,
or the detected problem and its fix.
- config.yml troubleshooting link repointed to the rendered wiki
page; bug_report.yml gains a diagnostic checkbox.
Diagnostic strings translated across all 8 locales. Backend service
unit tests (15) + frontend modal tests (3). Ruff clean, frontend
build clean, i18n parity green.
|
||
|
|
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.
|
||
|
|
5b3962e3e6 |
fix(obico): Failure Detection status panel shows thresholds for the selected sensitivity (#1469)
The Status panel's Low / High thresholds readout was stuck at 0.38 /
0.78 regardless of the Sensitivity dropdown, so the setting looked
dead. Detection itself was correct -- classify() always used the real
sensitivity -- but get_status() computed the displayed thresholds with
a hardcoded thresholds("medium").
get_status() now takes an optional sensitivity argument and the
/obico/status route passes settings["sensitivity"] (it already loads
settings fresh). The readout updates as soon as the change is saved.
|
||
|
|
ed27b27adb |
feat(slice): cross-printer re-slicing — drop the gate, the banner, and the dead plumbing
Step 0 empirical test on 2026-05-20 disproved the "CLI cannot re-slice a
3MF for a different printer" assumption: feeding an 18-color H2D-bound
Trent900.3mf to the X1C bundle via /slice produces valid X1C G-code in
1.8s, with bed (256x256), kinematics, nozzle count, machine_start_gcode,
and bed_exclude_area all coming from the target bundle.
- SliceModal: drop !printerMismatch from isReady; remove the banner and
the sourcePrinterModel / printerProfileName / printerMismatch state
entirely. Cross-printer slicing is now indistinguishable from a normal
slice; the picker already shows the target printer.
- Remove slice.printerMismatch from all 8 locales.
- API cleanup: drop source_printer_model from /library/files/{id}/plates
and /archives/{id}/plates responses, drop the field from
frontend/src/types/plates.ts (PlateMetadata + LibraryFilePlatesResponse),
delete extract_source_printer_model_from_3mf from threemf_tools.py and
its 6 unit tests. Zero remaining consumers.
- i18n discipline cleanup in SliceModal.tsx (same drop): strip every
inline English defaultValue / positional fallback from t() calls (22
sites). Add slice.bundle / slice.bundleNone / slice.bundleAllRequired
to all 8 locales — they had no entry in any locale file and were being
served from the inline English fallback for every non-English user.
- Tests: rewrite the mismatch-warning test to assert "no banner, Slice
enabled" when models differ (regression guard); delete 2 obsolete
tests covering gate states that no longer exist.
|
||
|
|
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. |
||
|
|
67cb5275d0 |
fix(camera): per-model profile registry; P2S gets relaxed RTSP probe (#1395)
Reporter on a P2S running firmware 01.02.00.00 saw the camera connect
for a few seconds then time out, repeating. P1S on the same install
worked fine — different protocol (chamber-image port 6000 vs RTSP via
ffmpeg).
The P2S RTSP path was running ffmpeg with `-probesize 32
-analyzeduration 0`, tuned for X1/H2 fast startup. The P2S's slower
keyframe pacing means ffmpeg can't lock onto the stream within 32
bytes — its own stderr says "consider increasing probesize" before
giving up after ~2s. Bambuddy reconnects, cycle repeats.
Instead of bumping the globals (which would regress every other RTSP
model's startup latency), this lifts the per-model tuning into a new
`camera_profiles` registry. CameraProfile dataclass holds the
previously-global knobs (probesize, analyzeduration, rtsp_reconnect_max,
rtsp_reconnect_delay, plus an extra_ffmpeg_input_args hook for future
per-model flags). get_camera_profile(model) returns the model's profile
or DEFAULT_PROFILE.
Default profile preserves the historical X1/H2 fast-startup values
verbatim — X1, X1C, X1E, X2D, H2C, H2D, H2D Pro, H2S all see no
behaviour change. P2S is the only override:
P2S: probesize=1_000_000, analyzeduration=500_000
SSDP internal codes (N7→P2S) resolve via an alias map so the camera
path works during the early-connect window before the display name
is settled.
This is the first step of the camera-architecture overhaul agreed
after #1395. Adding the next quirky model is a config entry, not
another module-level constant.
|
||
|
|
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. |
||
|
|
96fd4bb7e3 |
fix(printer): H2S could not start prints without AMS — was misclassified as dual-nozzle (#1386)
H2S is single-nozzle (nozzle_count=1 across 9+ stored support bundles
and the reporter's diagnostic) but had been added to the H-family model
gate in start_print_job. That single flag controlled both the firmware
bool->int format (legitimately needed for the whole H-family, including
H2S) and the dual-nozzle external-spool routing (correct only for actual
dual-extruder printers).
With no AMS attached and an external-spool slot (tray_id=254), the
dual-nozzle branch wrote ams_id=254 into ams_mapping2 instead of the
canonical 255 — exactly the failure the comment six lines above warns
against. Firmware rejected the dispatch with 07FF_8012 "Failed to get
AMS mapping table". The use_ams=False fallback was also being skipped
because the H-family bypass was meant for dual-nozzle routing.
A second site at bambu_mqtt.py:3987 and its sibling at kprofiles.py:119
detected dual-nozzle by serial prefix ("094", "20P9", "31B8B"). H2S
shares prefix "094" with H2D, so prefix detection misclassified it too.
Split the conflated flag into two:
- is_h_family — firmware format (int 0/1 for calibration fields).
Includes H2S. H2S firmware structurally accepted the current command
shape (failure was at AMS routing, not parsing), so the int format
stays for H2S.
- is_dual_nozzle — external-spool routing and use_ams gating. Excludes
H2S. Source-of-truth is the runtime _is_dual_nozzle flag set from
device.extruder.info, with a model-name fallback for the brief window
after connect before push data arrives.
The K-profile delete site and the kprofiles route now use the same
runtime+model check instead of serial prefix.
|
||
|
|
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.
|
||
|
|
c26303994a |
fix(security): use bandit nosec syntax for verify=False suppressions in support.py
The two # noqa: S501 comments on the local-sidecar reachability probes were using ruff/flake8 suppression syntax; bandit only honors # nosec, so the scan flagged both calls as high-severity. Switched to # nosec B501 with strengthened reasoning (reachability/health probe only, no secrets in the request). No behavioural change. |
||
|
|
5c24e6ed33 |
fix(security): use bandit nosec syntax for verify=False suppressions in support.py
The two # noqa: S501 comments on the local-sidecar reachability probes were using ruff/flake8 suppression syntax; bandit only honors # nosec, so the scan flagged both calls as high-severity. Switched to # nosec B501 with strengthened reasoning (reachability/health probe only, no secrets in the request). No behavioural change. |
||
|
|
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. |
||
|
|
ce5f4e5f1a |
fix(camera): don't open competing socket while a viewer is attached (#1348)
Obico polling could freeze the live camera stream within seconds of opening the viewer. Cause: when the buffer-reuse path in obico_detection._capture_frame saw an empty _last_frames[printer_id] entry (stream startup before the first JPEG lands, or upstream mid-reconnect after a 30s read timeout), it fell through to capture_camera_frame_bytes() and opened a second RTSP socket. On firmwares that allow only one camera connection, that second socket forced the printer to drop the live fan-out connection - the viewer's ffmpeg then hit its own 30s timeout, looped through 30 reconnects at 0.2s, all racing the next Obico poll, and the broadcaster pump exited. Widen the gate from "do we have a buffered frame?" to "is any fan-out stream registered for this printer?". New is_stream_active() helper checks _active_streams / _active_chamber_streams independently of buffer state. _capture_frame consults it first: if a viewer is attached, it returns the buffered frame when available or None (skip this poll cycle) when not. Never opens a competing socket while a viewer is connected. Cost: at most one missed Obico detection cycle per viewer-attach (~10s lag). Benefit: zero competing-socket events while any viewer is connected. try_get_active_buffered_frame() refactored to delegate to is_stream_active() so the two helpers stay in lockstep. The /camera/snapshot caller is unchanged behaviorally (snapshot is a user-initiated single-shot; falling through to fresh capture on an empty buffer is the desired behavior there). |
||
|
|
856b849ffa |
fix(stats): per-event aggregation so reprints add to Quick Stats instead of overwriting (#1378)
Statistics now aggregate over PrintLogEntry (one row per print event,
the same table backing the global Print Log) rather than PrintArchive
(one row per file). A reprint creates a new PrintLogEntry instead of
overwriting the source archive's runtime fields, so:
- a 100 g successful print + a 10 g failed reprint correctly sums to
110 g / 2 prints / 1 successful / 1 failed in Quick Stats and the
Prometheus /metrics endpoint (previously the failed reprint silently
replaced the source archive's data; totals dropped from 100 g to 10 g)
- the archive's card cost/energy_kwh are preserved on reprints (only
the first run writes them); per-run actuals live on PrintLogEntry
- failed/cancelled/stopped reprints record partial-aware filament: sum
of tracked spool deltas when inventory is set up, else estimate
scaled to progress%, else None — prevents the full slicer estimate
from inflating totals on a print that stopped at 10 % progress
PrintLogEntry gains six columns: archive_id (nullable FK, ON DELETE
SET NULL so log entries survive archive deletion preserving #1343
soft-delete-vs-stats decoupling), cost, energy_kwh, energy_cost,
failure_reason, created_by_id. Idempotent SQLite + Postgres migrations.
New per-archive surface:
- archive list response carries run_count / last_run_at /
total_filament_actual_grams / successful_run_count / failed_run_count
via a single batch JOIN, no N+1
- new GET /archives/{id}/runs endpoint returns every PrintLogEntry for
the archive (ARCHIVES_READ permission, newest-first ordering)
- archive cards render an orange "N prints" badge for archives with
more than one run; clicking the badge opens a dedicated PrintLogModal
with date/status/duration/filament/cost columns plus failure_reason
under failed runs. Also reachable via the context menu's new "Print
Log" entry (works for single-run archives too), and embedded at the
top of the Edit Archive modal for context.
The purge_stats=true delete path now hard-deletes linked PrintLogEntry
rows up front so the archive's contribution truly leaves the totals;
without it, ON DELETE SET NULL would orphan the runs and leave them
counting toward stats.
|
||
|
|
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. |
||
|
|
ae29a7dcd3 |
fix(api-keys): expose narrowly-scoped "Update electricity price" toggle (#1356)
Reporter @maziggy followed the Energy Tracking wiki literally - "create a
key with Write Settings permission, PATCH /api/v1/settings with
{energy_cost_per_kwh: ...}" - and hit:
{"detail":"API keys cannot be used for administrative operations"}.
Triage showed three independent drifts:
1. Wiki listed nine fictional API-key permissions (Read Printers / Write
Settings / Admin / ...) but the UI only ever exposed four toggles
(Read Status, Manage Queue, Control Printer, Allow Cloud Access).
There was no Write Settings toggle to tick.
2. Even if it had existed, the backend hard-denies SETTINGS_UPDATE for
every API key via _APIKEY_DENIED_PERMISSIONS - intentional protection
because PATCH /settings can rewrite SMTP/LDAP/MQTT credentials and the
HA access token. Wider surface than any documented use case needs.
3. So the wiki had been promising a workflow that was never deliverable.
Fix: introduce a narrowly-scoped door rather than relax the deny list.
- New column can_update_energy_cost (default FALSE - existing keys
never silently gain settings-write capability on upgrade).
- New route POST /api/v1/settings/electricity-price accepting
{"energy_cost_per_kwh": <float >= 0>}. Field name matches what the
wiki already documented so the HA rest_command example needs only a
URL+method change, not a payload change.
- Custom dependency require_energy_cost_update() bypasses
_APIKEY_DENIED_PERMISSIONS for this one route for API keys with the
flag set. JWT users still go through standard SETTINGS_UPDATE.
- General PATCH /settings remains denied for API keys - flipping the
narrow flag does NOT widen general settings-write access. Pinned by
test_patch_settings_still_denied_with_energy_flag.
Frontend: fifth "Update electricity price" toggle on the create-API-key
card + amber "Energy" badge on existing keys with the flag set. Three
new i18n keys across all 8 locales (German translated, English fallbacks
elsewhere).
|
||
|
|
d6364646f8 |
feat(auth): manual LDAP user provisioning from the UI (#1298)
Reporter @Fuechslein flagged that disabling LDAP auto-provision left admins
with no UI path to onboard new users — the create-user form had zero LDAP
awareness and the only workaround was hand-editing the database.
Add a Local / LDAP tab toggle to the create-user modal (hidden when LDAP is
disabled). The LDAP tab is a debounced directory search (≥2 chars, 300ms)
that returns up to 25 matches via the service-account bind, annotated with
already_provisioned so existing usernames render disabled. Clicking
"Provision user" re-resolves via the service bind and creates the user
through the same _provision_ldap_user helper the auto-provision login path
uses, so group mapping, default-group fallback, and email sync are identical
regardless of which path created the user.
The picker component is shared across all four create-user modal paths
(UsersPage basic + advanced, SettingsPage basic + advanced).
Two ldap3 schema-check workarounds were needed for OpenLDAP installs:
- Open the search connection with check_names=False so ldap3 doesn't reject
the cross-schema OR filter (sAMAccountName/displayName are AD-only)
- Request attributes=["*"] because ldap3's build_attribute_selection
validates each named attribute against the server schema regardless of
check_names, and only the * wildcard is in its hard-coded exclusion list
Login/lookup paths keep check_names=True so typos in user_filter still fail
loudly.
Backend
- New routes: GET /auth/ldap/search, POST /auth/ldap/provision (both gated
by USERS_CREATE; 503 details include ldap3 exception class + message)
- Extract _open_service_connection + _extract_user_info helpers so
authenticate_ldap_user, lookup_ldap_user, and search_ldap_users share the
bind and attribute-extraction logic
Frontend
- New LdapUserPicker component (debounced search, result list, provision
mutation, already-provisioned guard, error surface)
- Tab toggle wired into UsersPage and SettingsPage modals, plus
CreateUserAdvancedAuthModal props
- 14 i18n keys added to en.ts (other locales fall back to English)
|